From 97ec58df623ce8dc20c9300d57e50553c06a9df6 Mon Sep 17 00:00:00 2001 From: Michael Peter Christen Date: Sun, 2 Aug 2026 20:08:50 +0200 Subject: [PATCH 1/2] added Mac app builder --- apps/mac/.gitignore | 3 + apps/mac/Info.plist | 39 +++++ apps/mac/README.md | 204 +++++++++++++++++++++++ apps/mac/SketchForgeApp.swift | 291 +++++++++++++++++++++++++++++++++ apps/mac/SketchForgeMCP | 6 + apps/mac/build-app-dist.sh | 74 +++++++++ apps/mac/build-app.sh | 149 +++++++++++++++++ apps/mac/prepare-web-build.mjs | 58 +++++++ 8 files changed, 824 insertions(+) create mode 100644 apps/mac/.gitignore create mode 100644 apps/mac/Info.plist create mode 100644 apps/mac/README.md create mode 100644 apps/mac/SketchForgeApp.swift create mode 100755 apps/mac/SketchForgeMCP create mode 100755 apps/mac/build-app-dist.sh create mode 100755 apps/mac/build-app.sh create mode 100644 apps/mac/prepare-web-build.mjs diff --git a/apps/mac/.gitignore b/apps/mac/.gitignore new file mode 100644 index 0000000..7a55a08 --- /dev/null +++ b/apps/mac/.gitignore @@ -0,0 +1,3 @@ +.cache/ +dist/ +.DS_Store diff --git a/apps/mac/Info.plist b/apps/mac/Info.plist new file mode 100644 index 0000000..5b6f513 --- /dev/null +++ b/apps/mac/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + SketchForge + CFBundleExecutable + SketchForge + CFBundleIconFile + AppIcon.icns + CFBundleIdentifier + com.formsmith746.sketchforge + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + SketchForge + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.9.0 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.graphics-design + LSMinimumSystemVersion + 13.0 + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/apps/mac/README.md b/apps/mac/README.md new file mode 100644 index 0000000..427662e --- /dev/null +++ b/apps/mac/README.md @@ -0,0 +1,204 @@ +# SketchForge for macOS + +SketchForge for macOS packages the browser-based 3D editor as a standalone Mac +application. This README starts with installation and coding-agent setup for +people who downloaded the app. Build and architecture information for +developers follows afterward. + +## Install and run + +1. Open `SketchForge.dmg`. +2. Drag `SketchForge.app` onto the **Applications** shortcut. +3. Eject the disk image and start SketchForge from Applications. + +The disk image also includes this `README.md` and the project's `LICENSE` file. + +The distributed app is architecture-specific: use the Apple Silicon build on +an `arm64` Mac and the Intel build on an `x86_64` Mac. It requires macOS 13 or +later. + +`SketchForge.app` is self-contained. Running the editor does not require the +source repository, Docker, Homebrew, Node.js, npm, or an internet connection. +The web server, JavaScript runtime, assets, MCP adapter, and coding-agent skill +are all inside the application bundle. + +Shared project files are stored in: + +```text +~/Library/Application Support/SketchForge/Projects +``` + +Server diagnostics are written to the adjacent `Logs` directory. + +## Connect a coding agent + +SketchForge exposes its live editor to trusted local coding agents through MCP. +An agent can inspect the scene, create or modify objects, exercise CAD +operations, inspect errors, and capture viewport images. This is intended for +interactive development: changes made through MCP appear in the open editor. + +While the app is running, its web app and internal MCP bridge listen on +`127.0.0.1:3000`. The listener is restricted to IPv4 loopback and is not +reachable from the LAN. It has no authentication, so only run trusted local +agents while SketchForge is open. Closing the app stops the listener. + +### Attach Codex + +The HTTP route on port 3000 is an internal bridge, not a Streamable HTTP MCP +endpoint. Codex must start the STDIO adapter bundled inside `SketchForge.app`. +No source checkout or separate Node.js installation is needed. + +1. Start SketchForge and open or create a project so the editor is visible. + +2. If the app is installed in `/Applications`, register its bundled adapter: + + ```bash + codex mcp add sketchforge -- "/Applications/SketchForge.app/Contents/MacOS/SketchForgeMCP" + ``` + + If the app is stored elsewhere, replace `/Applications/SketchForge.app` with + its absolute path. + +3. Confirm that Codex knows about the server: + + ```bash + codex mcp list + ``` + +4. Optionally install the workflow skill bundled with the app: + + ```bash + ditto "/Applications/SketchForge.app/Contents/Resources/mcp/sketchforge-mcp-skill" \ + "$HOME/.codex/skills/sketchforge-mcp-skill" + ``` + +5. Restart Codex or begin a new Codex session. Use `/mcp` to confirm that + `sketchforge` is connected, then try: + + ```text + Use $sketchforge-mcp-skill to list my open SketchForge editors and inspect the current scene. + ``` + +Without the optional skill, ask Codex to use the SketchForge MCP tools directly. +An empty editor list normally means SketchForge is running but a project editor +is not open. If the app reports that port 3000 is occupied, stop the other local +process before relaunching SketchForge. + +--- + +## Developer guide + +The files in `apps/mac` build and package the existing Next.js application as a +native AppKit application with a `WKWebView` frontend and a bundled loopback +server. + +### Standalone design contract + +The generated `SketchForge.app` is the sole SketchForge artifact delivered to a +Mac user. Treat the following rules as mandatory for all future work in +`apps/mac`: + +- Editor use must require only macOS and `SketchForge.app`. Never introduce a + runtime dependency on the repository, Docker, Homebrew, Node.js, npm, or + another separately installed runtime. +- Coding-agent integration must be equally standalone. The Node.js runtime, + STDIO MCP adapter, Codex skill, web server, static assets, and every other + SketchForge runtime dependency belong inside the application bundle. +- User-facing MCP configuration may reference paths inside `SketchForge.app`, + but must never reference the source checkout or a system `node` executable. +- Keep the app relocatable. Bundled launchers must resolve resources relative + to their own location. If a user moves the app, only the external MCP + configuration path needs updating. +- Never write mutable runtime data into the signed bundle. Projects, logs, + preferences, and other state belong in the user's Library. +- Bind the web app and MCP bridge only to `127.0.0.1:3000`, never to `0.0.0.0` + or a LAN interface. Closing the app must terminate its web server. +- Implement Mac-specific behavior within `apps/mac` or disposable ignored + staging output. Do not modify existing SketchForge source outside `apps/mac`. + +Build-time tools are allowed on the developer machine creating the bundle; they +must not become requirements for the person receiving it. + +### How the bundle is assembled + +`build-app.sh` creates an isolated copy of `apps/web` under the ignored +`apps/mac/.cache` directory. `prepare-web-build.mjs` enables the localhost MCP +bridge only in that staged copy, after which Next.js produces a standalone +server. The original web source and Docker behavior remain unchanged. + +The important bundle contents are: + +```text +SketchForge.app/Contents/ +├── MacOS/ +│ ├── SketchForge native AppKit/WKWebView launcher +│ └── SketchForgeMCP relocatable STDIO MCP launcher +└── Resources/ + ├── runtime/node bundled Node.js runtime + ├── server/ standalone Next.js app and static assets + └── mcp/ + ├── sketchforge-mcp-server.mjs + └── sketchforge-mcp-skill/ +``` + +At runtime, the native launcher starts the bundled Next.js server on +`127.0.0.1:3000`, waits for it to become healthy, and loads it in `WKWebView`. +`SketchForgeMCP` resolves the application bundle from its own location and +starts the bundled adapter with the bundled Node.js runtime. + +### Build the application + +Building requires macOS 13 or later, Xcode Command Line Tools, Node.js/npm, +`curl`, and `tar`. Install the lockfile dependencies before the first build: + +```bash +npm ci +apps/mac/build-app.sh +``` + +The result is `apps/mac/dist/SketchForge.app`. The script downloads and caches +the matching official Node.js runtime on the first build. For an offline build, +provide an existing official archive with `--node-archive FILE`. Run +`apps/mac/build-app.sh --help` for all options. + +The output is architecture-specific and ad-hoc signed for local testing. Public +distribution requires signing with an Apple Developer ID certificate and Apple +notarization. + +### Build the distribution disk image + +After building the app, create the compressed distribution image: + +```bash +apps/mac/build-app-dist.sh +``` + +The result is `apps/mac/dist/SketchForge.dmg`. It contains +`SketchForge.app`, this `README.md`, the repository-root `LICENSE`, and an +`Applications` shortcut. The script verifies the app's code signature before +packaging and verifies the completed disk image. Use `--output FILE` to select +another destination. + +### Release verification + +Before shipping a build: + +1. Test `SketchForge.app` and `Contents/MacOS/SketchForgeMCP` from outside the + repository and without using a system Node.js executable. +2. Confirm that the running server is bound only to loopback: + + ```bash + lsof -nP -iTCP:3000 -sTCP:LISTEN + ``` + + The SketchForge listener must be shown as `127.0.0.1:3000`, never `*:3000`. + +3. Verify the application and disk image: + + ```bash + codesign --verify --deep --strict apps/mac/dist/SketchForge.app + hdiutil verify apps/mac/dist/SketchForge.dmg + ``` + +4. Confirm that the pull request contains no Mac-specific changes outside + `apps/mac`. diff --git a/apps/mac/SketchForgeApp.swift b/apps/mac/SketchForgeApp.swift new file mode 100644 index 0000000..8f8a5a0 --- /dev/null +++ b/apps/mac/SketchForgeApp.swift @@ -0,0 +1,291 @@ +import AppKit +import Darwin +import WebKit + +private let loopbackHost = "127.0.0.1" +private let loopbackPort: UInt16 = 3000 + +final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, WKNavigationDelegate, WKUIDelegate, WKDownloadDelegate { + private var window: NSWindow! + private var webView: WKWebView! + private var server: Process? + private var serverLog: FileHandle? + private var serverOrigin: URL? + private var isTerminating = false + + func applicationDidFinishLaunching(_ notification: Notification) { + installMainMenu() + createWindow() + NSApp.activate(ignoringOtherApps: true) + + do { + try startServer() + } catch { + let message = error.localizedDescription + NSLog("SketchForge startup failed: %@", message) + if let bytes = "Native startup failed: \(message)\n".data(using: .utf8) { + try? serverLog?.write(contentsOf: bytes) + try? serverLog?.synchronize() + } + presentStartupFailure(message) + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + func applicationWillTerminate(_ notification: Notification) { + isTerminating = true + if let server, server.isRunning { + server.terminate() + server.waitUntilExit() + } + try? serverLog?.close() + } + + private func createWindow() { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .default() + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + + webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = self + webView.uiDelegate = self + webView.setValue(false, forKey: "drawsBackground") + + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1320, height: 860), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "SketchForge" + window.tabbingMode = .disallowed + window.minSize = NSSize(width: 900, height: 620) + window.contentView = webView + window.delegate = self + window.center() + window.makeKeyAndOrderFront(nil) + } + + private func startServer() throws { + guard let resources = Bundle.main.resourceURL else { + throw AppError("The application resources could not be found.") + } + + let node = resources.appendingPathComponent("runtime/node") + let serverDirectory = resources.appendingPathComponent("server") + let serverScript = serverDirectory.appendingPathComponent("apps/web/server.js") + guard FileManager.default.isExecutableFile(atPath: node.path) else { + throw AppError("The bundled Node.js runtime is missing or is not executable.") + } + guard FileManager.default.fileExists(atPath: serverScript.path) else { + throw AppError("The bundled SketchForge server is missing.") + } + + let applicationSupport = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appendingPathComponent("SketchForge", isDirectory: true) + let projects = applicationSupport.appendingPathComponent("Projects", isDirectory: true) + let logs = applicationSupport.appendingPathComponent("Logs", isDirectory: true) + try FileManager.default.createDirectory(at: projects, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + + let logURL = logs.appendingPathComponent("server.log") + FileManager.default.createFile(atPath: logURL.path, contents: nil) + let logHandle = try FileHandle(forWritingTo: logURL) + try logHandle.truncate(atOffset: 0) + serverLog = logHandle + + let port = try availableLoopbackPort(requestedPort: loopbackPort) + let origin = URL(string: "http://\(loopbackHost):\(port)")! + serverOrigin = origin + + let process = Process() + process.executableURL = node + process.arguments = [serverScript.path] + process.currentDirectoryURL = serverDirectory + process.standardOutput = logHandle + process.standardError = logHandle + process.environment = ProcessInfo.processInfo.environment.merging([ + "NODE_ENV": "production", + "NEXT_TELEMETRY_DISABLED": "1", + "HOSTNAME": loopbackHost, + "PORT": String(port), + "SKETCHFORGE_ENABLE_MCP": "true", + "SKETCHFORGE_SHARED_PROJECTS_DIR": projects.path, + ]) { _, packagedValue in packagedValue } + process.terminationHandler = { [weak self] process in + DispatchQueue.main.async { + guard let self, !self.isTerminating else { return } + self.presentStartupFailure("The local SketchForge server stopped unexpectedly (exit code \(process.terminationStatus)). See \(logURL.path).") + } + } + + try process.run() + server = process + waitForServer(origin: origin, attemptsRemaining: 200) + } + + private func waitForServer(origin: URL, attemptsRemaining: Int) { + guard attemptsRemaining > 0 else { + presentStartupFailure("The local SketchForge server did not become ready. See ~/Library/Application Support/SketchForge/Logs/server.log.") + return + } + + var request = URLRequest(url: origin.appendingPathComponent("api/shared-projects")) + request.timeoutInterval = 1 + URLSession.shared.dataTask(with: request) { [weak self] _, response, _ in + let ready = (response as? HTTPURLResponse).map { (200..<500).contains($0.statusCode) } ?? false + DispatchQueue.main.asyncAfter(deadline: .now() + (ready ? 0 : 0.1)) { + guard let self else { return } + if ready { + self.webView.load(URLRequest(url: origin)) + } else if self.server?.isRunning == true { + self.waitForServer(origin: origin, attemptsRemaining: attemptsRemaining - 1) + } + } + }.resume() + } + + private func availableLoopbackPort(requestedPort: UInt16 = 0) throws -> UInt16 { + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw AppError("Could not allocate a local server socket.") } + defer { close(descriptor) } + + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = requestedPort.bigEndian + address.sin_addr = in_addr(s_addr: inet_addr(loopbackHost)) + + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + if requestedPort != 0 { + throw AppError("SketchForge's local port \(requestedPort) is already in use. Close the process using that port and reopen SketchForge.") + } + throw AppError("Could not reserve a local server port.") + } + + var length = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(descriptor, $0, &length) + } + } + guard named == 0 else { throw AppError("Could not determine the local server port.") } + return UInt16(bigEndian: address.sin_port) + } + + private func presentStartupFailure(_ message: String) { + guard window != nil else { return } + let html = """ +

SketchForge could not start

\(escapeHTML(message))
+ """ + webView.loadHTMLString(html, baseURL: nil) + } + + private func escapeHTML(_ value: String) -> String { + value.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let url = navigationAction.request.url else { + decisionHandler(.cancel) + return + } + if navigationAction.shouldPerformDownload { + decisionHandler(.download) + } else if isLocalAppURL(url) || url.scheme == "about" || url.scheme == "blob" { + decisionHandler(.allow) + } else { + NSWorkspace.shared.open(url) + decisionHandler(.cancel) + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + decisionHandler(navigationResponse.canShowMIMEType ? .allow : .download) + } + + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + download.delegate = self + } + + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + download.delegate = self + } + + func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) { + let panel = NSSavePanel() + panel.nameFieldStringValue = suggestedFilename + panel.canCreateDirectories = true + panel.beginSheetModal(for: window) { result in + completionHandler(result == .OK ? panel.url : nil) + } + } + + private func isLocalAppURL(_ url: URL) -> Bool { + guard let origin = serverOrigin else { return false } + return url.scheme == origin.scheme && url.host == origin.host && url.port == origin.port + } + + private func installMainMenu() { + let mainMenu = NSMenu() + let appItem = NSMenuItem() + mainMenu.addItem(appItem) + let appMenu = NSMenu() + appMenu.addItem(withTitle: "About SketchForge", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Quit SketchForge", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + appItem.submenu = appMenu + + let editItem = NSMenuItem() + mainMenu.addItem(editItem) + let editMenu = NSMenu(title: "Edit") + editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") + editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + editItem.submenu = editMenu + NSApp.mainMenu = mainMenu + } +} + +private struct AppError: LocalizedError { + let message: String + init(_ message: String) { self.message = message } + var errorDescription: String? { message } +} + +@main +private enum SketchForgeMain { + static func main() { + let application = NSApplication.shared + let delegate = AppDelegate() + application.delegate = delegate + application.setActivationPolicy(.regular) + if let iconURL = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"), + let icon = NSImage(contentsOf: iconURL) { + application.applicationIconImage = icon + } + application.run() + withExtendedLifetime(delegate) {} + } +} diff --git a/apps/mac/SketchForgeMCP b/apps/mac/SketchForgeMCP new file mode 100755 index 0000000..e80efc4 --- /dev/null +++ b/apps/mac/SketchForgeMCP @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu + +CONTENTS_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)" +exec "$CONTENTS_DIR/Resources/runtime/node" \ + "$CONTENTS_DIR/Resources/mcp/sketchforge-mcp-server.mjs" diff --git a/apps/mac/build-app-dist.sh b/apps/mac/build-app-dist.sh new file mode 100755 index 0000000..67a078d --- /dev/null +++ b/apps/mac/build-app-dist.sh @@ -0,0 +1,74 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +DIST_DIR="$SCRIPT_DIR/dist" +APP_PATH="$DIST_DIR/SketchForge.app" +OUTPUT_DMG="$DIST_DIR/SketchForge.dmg" + +usage() { + sed -n '/^# Package the built/,/^# The disk image contains/p' "$0" | sed 's/^# \{0,1\}//' +} + +# Package SketchForge.app, the Mac README, and the project license in a disk image. +# +# Usage: apps/mac/build-app-dist.sh [--output FILE] +# +# Options: +# --output FILE Write the disk image to FILE +# (default: apps/mac/dist/SketchForge.dmg). +# -h, --help Show this help. +# +# The disk image contains SketchForge.app, README.md, LICENSE, and an Applications link. + +while [[ $# -gt 0 ]]; do + case "$1" in + --output) OUTPUT_DMG="${2:?--output needs a file path}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +for command_name in codesign ditto hdiutil; do + command -v "$command_name" >/dev/null || { echo "Required command not found: $command_name" >&2; exit 1; } +done + +[[ -d "$APP_PATH" ]] || { + echo "Built application not found: $APP_PATH" >&2 + echo "Run apps/mac/build-app.sh first." >&2 + exit 1 +} +[[ -f "$SCRIPT_DIR/README.md" ]] || { echo "README not found: $SCRIPT_DIR/README.md" >&2; exit 1; } +[[ -f "$REPO_ROOT/LICENSE" ]] || { echo "License not found: $REPO_ROOT/LICENSE" >&2; exit 1; } + +codesign --verify --deep --strict "$APP_PATH" + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/sketchforge-dmg.XXXXXX")" +PAYLOAD_DIR="$WORK_DIR/payload" +TEMP_DMG="$WORK_DIR/SketchForge.dmg" +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +mkdir -p "$PAYLOAD_DIR" +ditto "$APP_PATH" "$PAYLOAD_DIR/SketchForge.app" +cp "$SCRIPT_DIR/README.md" "$PAYLOAD_DIR/README.md" +cp "$REPO_ROOT/LICENSE" "$PAYLOAD_DIR/LICENSE" +ln -s /Applications "$PAYLOAD_DIR/Applications" + +echo "Creating compressed disk image..." +hdiutil create \ + -volname "SketchForge" \ + -srcfolder "$PAYLOAD_DIR" \ + -format UDZO \ + -ov \ + "$TEMP_DMG" +hdiutil verify "$TEMP_DMG" + +mkdir -p "$(dirname "$OUTPUT_DMG")" +mv -f "$TEMP_DMG" "$OUTPUT_DMG" + +echo +echo "Created: $OUTPUT_DMG" diff --git a/apps/mac/build-app.sh b/apps/mac/build-app.sh new file mode 100755 index 0000000..c379040 --- /dev/null +++ b/apps/mac/build-app.sh @@ -0,0 +1,149 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="$SCRIPT_DIR/dist" +NODE_VERSION="$(node -p 'process.version' 2>/dev/null || true)" +NODE_ARCHIVE="" +SKIP_WEB_BUILD=false + +usage() { + sed -n '/^# Build a self-contained/,/^# The resulting app includes/p' "$0" | sed 's/^# \{0,1\}//' +} + +# Build a self-contained SketchForge.app for the current Mac architecture. +# +# Usage: apps/mac/build-app.sh [options] +# +# Options: +# --output DIR Put SketchForge.app in DIR (default: apps/mac/dist). +# --node-version VER Bundle this official Node.js release (default: the +# version of node used for the web build). +# --node-archive FILE Use an already-downloaded official node-VER-darwin-ARCH +# .tar.gz archive instead of downloading it. +# --skip-web-build Reuse apps/web/.next/standalone from an earlier build. +# -h, --help Show this help. +# +# Requirements: macOS 13+, Xcode Command Line Tools, Node.js/npm, curl, and tar. +# The resulting app includes Node.js; end users do not need Node or Docker. + +while [[ $# -gt 0 ]]; do + case "$1" in + --output) OUTPUT_DIR="${2:?--output needs a directory}"; shift 2 ;; + --node-version) NODE_VERSION="${2:?--node-version needs a version}"; shift 2 ;; + --node-archive) NODE_ARCHIVE="${2:?--node-archive needs a file}"; shift 2 ;; + --skip-web-build) SKIP_WEB_BUILD=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +for command_name in node npm xcrun ditto sips iconutil codesign; do + command -v "$command_name" >/dev/null || { echo "Required command not found: $command_name" >&2; exit 1; } +done + +[[ -n "$NODE_VERSION" ]] || { echo "Node.js is required to build the web application." >&2; exit 1; } +[[ "$NODE_VERSION" == v* ]] || NODE_VERSION="v$NODE_VERSION" + +case "$(uname -m)" in + arm64) NODE_ARCH="arm64"; SWIFT_ARCH="arm64" ;; + x86_64) NODE_ARCH="x64"; SWIFT_ARCH="x86_64" ;; + *) echo "Unsupported Mac architecture: $(uname -m)" >&2; exit 1 ;; +esac + +APP_VERSION="$(node -p "require('$REPO_ROOT/package.json').version")" +APP_PATH="$OUTPUT_DIR/SketchForge.app" +CONTENTS="$APP_PATH/Contents" +CACHE_DIR="$SCRIPT_DIR/.cache" +MAC_WEB_ROOT="$CACHE_DIR/web" +MAC_WEB_STANDALONE_RELATIVE="apps/mac/.cache/web" +RUNTIME_ROOT="$CACHE_DIR/node-$NODE_VERSION-darwin-$NODE_ARCH" +DOWNLOADED_ARCHIVE="$CACHE_DIR/node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz" + +mkdir -p "$CACHE_DIR" "$OUTPUT_DIR" +if ! $SKIP_WEB_BUILD; then + echo "Preparing an isolated MCP-enabled web build..." + (cd "$REPO_ROOT" && npm run copy:occt) + node "$SCRIPT_DIR/prepare-web-build.mjs" "$REPO_ROOT/apps/web" "$MAC_WEB_ROOT" + echo "Building the standalone SketchForge web server..." + (cd "$REPO_ROOT" && SKETCHFORGE_DOCKER_BUILD=true NEXT_TELEMETRY_DISABLED=1 \ + "$REPO_ROOT/node_modules/.bin/next" build "$MAC_WEB_ROOT") +fi + +STANDALONE="$MAC_WEB_ROOT/.next/standalone" +STAGED_SERVER="$STANDALONE/$MAC_WEB_STANDALONE_RELATIVE" +[[ -f "$STAGED_SERVER/server.js" ]] || { + echo "Standalone server not found at $STAGED_SERVER/server.js" >&2 + echo "Run without --skip-web-build to create it." >&2 + exit 1 +} + +if [[ ! -x "$RUNTIME_ROOT/bin/node" ]]; then + if [[ -n "$NODE_ARCHIVE" ]]; then + [[ -f "$NODE_ARCHIVE" ]] || { echo "Node archive not found: $NODE_ARCHIVE" >&2; exit 1; } + SOURCE_ARCHIVE="$NODE_ARCHIVE" + else + SOURCE_ARCHIVE="$DOWNLOADED_ARCHIVE" + if [[ ! -f "$SOURCE_ARCHIVE" ]]; then + NODE_URL="https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz" + echo "Downloading the official Node.js $NODE_VERSION $NODE_ARCH runtime..." + curl --fail --location --retry 3 --output "$SOURCE_ARCHIVE.part" "$NODE_URL" + mv "$SOURCE_ARCHIVE.part" "$SOURCE_ARCHIVE" + fi + fi + tar -xzf "$SOURCE_ARCHIVE" -C "$CACHE_DIR" +fi + +echo "Assembling $APP_PATH..." +rm -rf "$APP_PATH" +mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources/runtime" "$CONTENTS/Resources/server/apps/web/.next" + +cp "$SCRIPT_DIR/Info.plist" "$CONTENTS/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $APP_VERSION" "$CONTENTS/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $(date -u +%Y%m%d%H%M)" "$CONTENTS/Info.plist" + +CLANG_MODULE_CACHE_PATH="$CACHE_DIR/clang-module-cache" xcrun swiftc \ + -O \ + -parse-as-library \ + -target "$SWIFT_ARCH-apple-macos13.0" \ + -framework AppKit \ + -framework WebKit \ + "$SCRIPT_DIR/SketchForgeApp.swift" \ + -o "$CONTENTS/MacOS/SketchForge" + +cp "$SCRIPT_DIR/SketchForgeMCP" "$CONTENTS/MacOS/SketchForgeMCP" +chmod 755 "$CONTENTS/MacOS/SketchForgeMCP" +cp "$RUNTIME_ROOT/bin/node" "$CONTENTS/Resources/runtime/node" +chmod 755 "$CONTENTS/Resources/runtime/node" +mkdir -p "$CONTENTS/Resources/mcp" +cp "$REPO_ROOT/scripts/sketchforge-mcp-server.mjs" "$CONTENTS/Resources/mcp/sketchforge-mcp-server.mjs" +ditto "$REPO_ROOT/docs/skills/sketchforge-mcp-skill/" "$CONTENTS/Resources/mcp/sketchforge-mcp-skill/" +ditto "$STANDALONE/" "$CONTENTS/Resources/server/" +ditto "$STAGED_SERVER/" "$CONTENTS/Resources/server/apps/web/" +rm -rf "$CONTENTS/Resources/server/apps/mac" +ditto "$MAC_WEB_ROOT/.next/static/" "$CONTENTS/Resources/server/apps/web/.next/static/" +ditto "$MAC_WEB_ROOT/public/" "$CONTENTS/Resources/server/apps/web/public/" + +ICONSET="$CACHE_DIR/AppIcon.iconset" +rm -rf "$ICONSET" +mkdir -p "$ICONSET" +ICON_SOURCE="$REPO_ROOT/apps/web/public/assets/sketchforge/sketchforge-logo.png" +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$ICON_SOURCE" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null + double_size=$((size * 2)) + sips -z "$double_size" "$double_size" "$ICON_SOURCE" --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns" + +# Ad-hoc signing makes the nested runtime and app bundle internally consistent. +# Distribution outside the building Mac can replace this with Developer ID signing +# and notarization after this script completes. +codesign --force --deep --sign - "$APP_PATH" +codesign --verify --deep --strict "$APP_PATH" + +echo +echo "Created: $APP_PATH" +echo "Architecture: $NODE_ARCH" +echo "Node.js runtime: $NODE_VERSION" +echo "Projects will be stored in: ~/Library/Application Support/SketchForge/Projects" diff --git a/apps/mac/prepare-web-build.mjs b/apps/mac/prepare-web-build.mjs new file mode 100644 index 0000000..73a421c --- /dev/null +++ b/apps/mac/prepare-web-build.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +const [sourceArgument, targetArgument] = process.argv.slice(2); +if (!sourceArgument || !targetArgument) { + console.error("Usage: prepare-web-build.mjs SOURCE_WEB_DIR TARGET_WEB_DIR"); + process.exit(2); +} + +const sourceDirectory = path.resolve(sourceArgument); +const targetDirectory = path.resolve(targetArgument); +if (sourceDirectory === targetDirectory || !targetDirectory.includes(`${path.sep}apps${path.sep}mac${path.sep}.cache${path.sep}`)) { + throw new Error(`Refusing to replace unsafe staging directory: ${targetDirectory}`); +} + +await rm(targetDirectory, { recursive: true, force: true }); +await mkdir(path.dirname(targetDirectory), { recursive: true }); +await cp(sourceDirectory, targetDirectory, { + recursive: true, + filter(source) { + const relative = path.relative(sourceDirectory, source); + return !relative.split(path.sep).some((part) => part === ".next" || part === ".next-dev" || part === ".next-export"); + }, +}); + +async function transform(relativePath, transform) { + const filePath = path.join(targetDirectory, relativePath); + const original = await readFile(filePath, "utf8"); + const updated = transform(original); + if (updated === original) { + throw new Error(`Mac MCP overlay did not match ${relativePath}; update prepare-web-build.mjs for the current web source.`); + } + await writeFile(filePath, updated, "utf8"); +} + +await transform("src/app/api/sketchforge-mcp/route.ts", (source) => { + const guard = 'if (process.env.NODE_ENV === "production") {'; + const matches = source.split(guard).length - 1; + if (matches !== 1) throw new Error(`Expected one MCP production route guard, found ${matches}.`); + return source.replace(guard, 'if (process.env.SKETCHFORGE_ENABLE_MCP !== "true") {'); +}); + +await transform("src/components/SketchForgeEditor.tsx", (source) => { + const anchor = "const identity = readMcpEditorIdentity();"; + const guard = 'if (process.env.NODE_ENV === "production" || typeof window === "undefined") {'; + const anchorIndex = source.indexOf(anchor); + if (anchorIndex < 0) throw new Error("Could not find the MCP editor identity anchor."); + const guardIndex = source.lastIndexOf(guard, anchorIndex); + if (guardIndex < 0 || anchorIndex - guardIndex > 500) { + throw new Error("Could not find the MCP client production guard near its identity setup."); + } + return `${source.slice(0, guardIndex)}if (typeof window === "undefined") {${source.slice(guardIndex + guard.length)}`; +}); + +console.log(`[mac-build] prepared isolated MCP-enabled web source at ${targetDirectory}`); From 0dbefbd25ccdb3162e2f097dcd87a211196103d9 Mon Sep 17 00:00:00 2001 From: Michael Peter Christen Date: Tue, 4 Aug 2026 17:00:56 +0200 Subject: [PATCH 2/2] installation hints --- apps/mac/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/mac/README.md b/apps/mac/README.md index 427662e..f6f2dd5 100644 --- a/apps/mac/README.md +++ b/apps/mac/README.md @@ -10,6 +10,17 @@ developers follows afterward. 1. Open `SketchForge.dmg`. 2. Drag `SketchForge.app` onto the **Applications** shortcut. 3. Eject the disk image and start SketchForge from Applications. +4. If macOS prevents the app from opening because it cannot verify the + developer, close the warning and open **System Settings > Privacy & + Security**. Scroll down to the **Security** section, find the message about + SketchForge, and click **Open Anyway**. Authenticate with your password or + Touch ID when prompted, then confirm that you want to open the app. + +An ad-hoc-signed development build will not start on another Mac until the user +explicitly approves it under **Privacy & Security**. This approval is normally +required only the first time that particular build is opened. A release signed +with an Apple Developer ID and notarized by Apple does not require this manual +exception. The disk image also includes this `README.md` and the project's `LICENSE` file.