From 4ab853076f1f40c4d96d51527e1f2105b0d8d9d8 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Sat, 27 Jun 2026 17:35:25 -0700 Subject: [PATCH] fix(bootstrap): copy macOS app with ditto so it is not 'damaged' Installing the desktop app used cpSync to copy OpenWork.app out of the mounted DMG. Node's recursive copy left framework symlinks (e.g. Squirrel.framework Versions/Current) pointing at the temporary DMG mount path, which is unmounted right after install. The dangling links break the bundle's code signature, so macOS Gatekeeper reports 'OpenWork is damaged and can't be opened.' Fix: copy .app bundles with ditto (Apple-supported, preserves relative framework symlinks + signature), with a cp -R fallback, and strip the com.apple.quarantine flag on the freshly installed (already-notarized) app. Verified: a fresh install now passes codesign --verify --deep --strict (valid on disk, satisfies Designated Requirement) and spctl --assess (accepted, source=Notarized Developer ID). --- packages/openwork-bootstrap/bin/openwork.mjs | 28 ++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/openwork-bootstrap/bin/openwork.mjs b/packages/openwork-bootstrap/bin/openwork.mjs index d9be7253f..412c289af 100644 --- a/packages/openwork-bootstrap/bin/openwork.mjs +++ b/packages/openwork-bootstrap/bin/openwork.mjs @@ -261,12 +261,36 @@ function findInstallCandidate(root, expectedName) { throw new Error(`app_not_found_in_archive: ${expectedName}`) } +// Copy an installed artifact into place. macOS .app bundles contain internal +// framework symlinks (e.g. Versions/Current, the framework binary/Resources +// links) that a naive recursive copy can break — leaving dangling links into a +// now-unmounted DMG, which makes Gatekeeper report the app as "damaged". Use +// `ditto` on macOS, which is the Apple-supported way to copy bundles while +// preserving relative symlinks and the code signature. +function copyArtifact(source, target) { + if (process.platform === "darwin") { + try { + execFileSync("ditto", [source, target], { stdio: "pipe" }) + } catch { + // Fall back to cp -R (also preserves bundle symlinks) before giving up. + execFileSync("cp", ["-R", source, target], { stdio: "pipe" }) + } + // Remove the quarantine flag so Gatekeeper does not block the freshly + // installed (already-notarized) app on first launch. Best-effort. + try { + execFileSync("xattr", ["-dr", "com.apple.quarantine", target], { stdio: "pipe" }) + } catch {} + return + } + cpSync(source, target, { recursive: true }) +} + function installFromDirectory(input) { const source = findInstallCandidate(input.sourceDir, input.appName) mkdirSync(input.appDir, { recursive: true }) const target = join(input.appDir, input.appName) rmSync(target, { recursive: true, force: true }) - cpSync(source, target, { recursive: true }) + copyArtifact(source, target) if (input.executable) chmodSync(target, 0o755) return target } @@ -290,7 +314,7 @@ function installDmg(input) { mkdirSync(input.appDir, { recursive: true }) const targetApp = join(input.appDir, appName) rmSync(targetApp, { recursive: true, force: true }) - cpSync(sourceApp, targetApp, { recursive: true }) + copyArtifact(sourceApp, targetApp) return targetApp } finally { if (mounted) {