From e635a032ea152ef832930bb19b6a129b727631bd Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Tue, 18 Aug 2026 11:55:17 +0200 Subject: [PATCH] Sign the macOS bundle, or it arrives "damaged" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first release could not be opened: macOS said "WarpEngine Store is damaged and can't be opened. You should move it to the Bin." Not a wording problem — an integrity one. electron-builder found no signing identity and skipped signing, so the bundle kept only the linker's ad-hoc signature on its main executable, with no resource seal. `codesign --verify` said "code has no resources but signature indicates they must be present", and Gatekeeper reports that as damaged and offers no way past it, unlike an un-notarised app which can at least be approved. `scripts/after-pack.js` now signs the bundle itself during packaging. Measured on a copy unzipped from the artifact with the quarantine flag set by hand: before code has no resources but signature indicates they must be present after valid on disk; satisfies its Designated Requirement and the identifier is ours rather than `Electron`. `syspolicy_check` is down to its expected "adhoc signed" warning. A downloaded copy still has to be approved — that is Gatekeeper policy for anything un-notarised, and notarisation needs a paid Developer ID — so the README and the release notes lead with the one command that does it. Two smaller things the failure turned up: - The self-test was passing silently. With a copy of the app already open, the second process lost the single-instance lock and exited 0 with no output, which reads exactly like success. It now uses its own user-data directory and skips the lock, and it caught a real launch failure immediately afterwards. - The README claimed right-click ▸ Open was enough. It was not, and I had not checked it — replaced with what the measurements support. v1.0.0's attachments are withdrawn rather than left downloadable. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 44 +++++++++++++++++++++++++++++++++++-------- main.js | 9 ++++++++- package.json | 5 +++-- scripts/after-pack.js | 32 +++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 scripts/after-pack.js diff --git a/README.md b/README.md index fcc6d04..57a2d23 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,27 @@ Grab the package for your machine from the and open it. On first run, if there is no store on the machine yet, the window offers to download one — that is the whole setup. -The macOS build is **not signed or notarised**, so the first open needs -*right-click ▸ Open* (or *System Settings ▸ Privacy & Security*). Nothing the -store itself downloads is affected: those files are fetched by Python, which does -not set the quarantine flag. +### Opening it on macOS + +The build is ad-hoc signed but **not notarised**, so macOS asks before running a +copy that came from a browser. The reliable way through: + +```sh +xattr -dr com.apple.quarantine "/Applications/WarpEngine Store.app" +``` + +If macOS offers *Open Anyway* under **System Settings ▸ Privacy & Security** after +a blocked attempt, that works as well. Notarisation is the only thing that removes +the step entirely, and it needs a paid Apple Developer ID. + +Nothing the store itself downloads is affected: Python fetches those files, and +Python does not set the quarantine flag. + +**v1.0.0 could not be opened at all** — it reported *"is damaged"*. The bundle had +never been signed; only its main executable carried the linker's ad-hoc signature, +so there was no resource seal and Gatekeeper refused it outright rather than +asking. `scripts/after-pack.js` signs the bundle during the build now, and the +result verifies as `valid on disk`. ## Use @@ -61,6 +78,10 @@ npm run dist:mac # or dist:win / dist:linux is ESM-only, and older Node cannot `require()` it. The packaged app carries its own runtime. +`npm run uitest` runs with its own user-data directory and without the +single-instance lock. Otherwise a copy the user already has open swallows the test +process, which exits 0 and reads as a pass. + Both test scripts accept a sandbox store instead of the real one, which is how this repository is tested without touching a working installation: @@ -95,10 +116,17 @@ too — that is why the indirection is there. ## Verified, and not -Exercised on macOS (arm64): the store is discovered, the catalog lists, a sync -installs, the window renders the installed state, and `npm run uitest` passes with -the grid rendered and both languages in the picker. The bootstrap download was run -into an empty directory and the resulting store answered the bridge. +Exercised on macOS (arm64), with the packaged app from the release rather than a +dev run: the store is discovered, the catalog lists, a sync installs, the window +renders the installed state, and `npm run uitest` passes with the grid rendered +and both languages in the picker. The bootstrap download was run into an empty +directory and the resulting store answered the bridge. + +The signing was measured rather than assumed, by setting the quarantine flag on a +copy unzipped from the release artifact: `codesign --verify --deep --strict` is +clean, and `syspolicy_check` reports only the expected *"adhoc signed"* warning. +A quarantined copy is still stopped until it is approved — that part is Gatekeeper +policy, not a fault in the package. **Not tried on Linux or Windows.** The paths and the launch behaviour are written for them, and the store CLI itself has the same gap — `.desktop` and `.lnk` diff --git a/main.js b/main.js index 4e5ebad..3a412b6 100644 --- a/main.js +++ b/main.js @@ -60,6 +60,13 @@ async function guarded (fn) { // would otherwise be noticed: the main process log stays empty. const SELFTEST = process.argv.includes('--selftest') +// A test run must never be swallowed by a copy the user already has open: it gets +// its own user-data directory and skips the single-instance lock. Without this the +// second process exits silently with status 0, which reads as a passing test. +if (SELFTEST) { + app.setPath('userData', path.join(app.getPath('temp'), 'warpstore-gui-selftest')) +} + async function selftest () { const result = await win.webContents.executeJavaScript(`(() => ({ cards: document.querySelectorAll('.card').length, @@ -215,7 +222,7 @@ ipcMain.handle('app:openExternal', async (_event, url) => { // --- lifecycle ------------------------------------------------------------ -if (!app.requestSingleInstanceLock()) { +if (!SELFTEST && !app.requestSingleInstanceLock()) { app.quit() } else { app.on('second-instance', () => { diff --git a/package.json b/package.json index 886eeb4..d1832f2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "warp-engine-desktop-gui", "productName": "WarpEngine Store", - "version": "1.0.0", + "version": "1.0.1", "description": "Graphical client for a WarpEngine desktop store: install the catalog into your own application menu.", "license": "MIT", "author": "Teletype Games ", @@ -51,7 +51,8 @@ "AppImage", "deb" ] - } + }, + "afterPack": "scripts/after-pack.js" }, "allowScripts": { "electron@43.4.0": true diff --git a/scripts/after-pack.js b/scripts/after-pack.js new file mode 100644 index 0000000..37f94ab --- /dev/null +++ b/scripts/after-pack.js @@ -0,0 +1,32 @@ +'use strict' +// Ad-hoc sign the macOS bundle after packing. +// +// Without this the bundle carries only the linker's ad-hoc signature on the main +// executable, with no resource seal — `codesign --verify` says "code has no +// resources but signature indicates they must be present". That runs fine +// locally, but a browser download adds the quarantine flag, Gatekeeper evaluates +// the broken seal, and macOS reports the app as *damaged* rather than merely +// unverified. The first release shipped exactly that. +// +// An ad-hoc signature is not a Developer ID and does not notarise anything: the +// user still has to right-click ▸ Open the first time. It is the difference +// between "unidentified developer" and "move it to the Bin". + +const { execFileSync } = require('node:child_process') +const path = require('node:path') + +exports.default = async function afterPack (context) { + if (context.electronPlatformName !== 'darwin') return + if (process.platform !== 'darwin') { + console.log(' • ad-hoc signing skipped reason=codesign only exists on macOS') + return + } + + const app = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`) + // --deep is the pragmatic choice for ad-hoc signing a bundle with nested + // frameworks and helpers; Apple discourages it for real identities, where the + // inner-to-outer order matters. + execFileSync('codesign', ['--force', '--deep', '--sign', '-', app], { stdio: 'inherit' }) + execFileSync('codesign', ['--verify', '--deep', '--strict', '--verbose=1', app], { stdio: 'inherit' }) + console.log(` • ad-hoc signed ${app}`) +}