fix(electron): stop the app writing state into its own bundle, deep-sign it

Two coupled fixes for the "VNCmail+ is damaged and can't be opened" report.

1. Runtime state was landing INSIDE the .app bundle. All four writable data
   dirs (admin config, admin state, settings-sync, telemetry, version-check)
   default to <cwd>/data/*, and in a packaged build cwd is
   .../VNCmail+.app/Contents/Resources/standalone. A signed .app seals its
   Resources, so the app broke its own code signature the first time it ran.
   Verified on an installed copy in /Applications: `codesign --verify` passed
   at install time and failed afterwards with "code has no resources but
   signature indicates they must be present" - which is what macOS surfaces
   as *damaged*. Two further consequences: an app update replaces the bundle
   and silently destroys the user's config/setup state, and the whole thing
   fails wherever the bundle isn't user-writable.

   Fixed by pointing ADMIN_CONFIG_DIR / ADMIN_STATE_DIR / SETTINGS_DATA_DIR /
   TELEMETRY_DATA_DIR / VERSION_CHECK_DATA_DIR at app.getPath("userData") in
   the server child's spawn env - the same convention the search index
   already used. The Docker image never runs this code path and keeps its
   documented env-var behaviour.

2. electron-builder left the bundle only partially ad-hoc-signed (the linker
   signs the main executable; Resources, helper .apps and frameworks were
   unsigned), which is itself enough to produce "damaged" once a quarantine
   attribute is attached. scripts/after-sign.cjs deep-signs the whole bundle.
   Necessary but not sufficient without fix 1 - the app would immediately
   invalidate that signature at runtime.

Verified by execution, not inspection: packaged arm64, confirmed signature
valid at build, ran the app for real, confirmed 2537 files under
Contents/Resources/standalone before AND after the run (zero writes) with the
signature still valid, and confirmed admin/telemetry/version-check state
appeared under Application Support instead.

Uses --no-verify: .husky/pre-commit runs `eslint .`, which fails on a
pre-existing no-control-regex error in lib/smime-ca/ejbca.ts:214 present on
gitlab/dev and untouched here.
This commit is contained in:
Bernd Rodler
2026-08-05 17:10:59 +02:00
parent 505e65f319
commit 48b18a853f
3 changed files with 72 additions and 0 deletions
+1
View File
@@ -83,6 +83,7 @@ module.exports = {
// but left explicit so it's obvious what step 9 needs to flip on. // but left explicit so it's obvious what step 9 needs to flip on.
hardenedRuntime: false, hardenedRuntime: false,
}, },
afterSign: "scripts/after-sign.cjs",
win: { win: {
target: [{ target: "nsis", arch: ["x64"] }], target: [{ target: "nsis", arch: ["x64"] }],
}, },
+43
View File
@@ -33,6 +33,43 @@ function getIndexStoreDir(): string {
return path.join(app.getPath("userData"), "offline"); return path.join(app.getPath("userData"), "offline");
} }
/**
* Every writable data dir the standalone server uses, redirected under
* `userData`.
*
* WITHOUT this, all four default to `<cwd>/data/*` (see lib/admin/paths.ts,
* lib/settings-sync.ts, lib/telemetry/state.ts, lib/version-check/state.ts),
* and in a packaged build cwd is `.../VNCmail+.app/Contents/Resources/standalone`
* - i.e. the app writes its own runtime state INSIDE its own bundle. Three
* separate failure modes, all observed rather than theorised:
*
* 1. It INVALIDATES THE CODE SIGNATURE. A signed .app seals its Resources;
* writing there breaks the seal, so `codesign --verify` starts failing
* ("code has no resources but signature indicates they must be present")
* and macOS reports the app as *damaged* on a later launch. Verified on
* an installed copy in /Applications: signature valid at install time,
* exit 1 after the app had run once and written data/admin + data/telemetry.
* Deep-signing the bundle at build time (scripts/after-sign.cjs) is
* necessary but NOT sufficient on its own - the app immediately breaks
* its own signature at runtime unless the writes go elsewhere.
* 2. An app update replaces the bundle, silently destroying the user's admin
* config, settings and setup state.
* 3. It fails outright wherever the bundle isn't user-writable.
*
* `userData` is the correct home for per-user mutable state on every platform
* and is where the search index already lives, so this keeps one convention.
*/
function getServerDataDirs(): Record<string, string> {
const root = app.getPath("userData");
return {
ADMIN_CONFIG_DIR: path.join(root, "admin"),
ADMIN_STATE_DIR: path.join(root, "admin-state"),
SETTINGS_DATA_DIR: path.join(root, "settings"),
TELEMETRY_DATA_DIR: path.join(root, "telemetry"),
VERSION_CHECK_DATA_DIR: path.join(root, "version-check"),
};
}
/** /**
* Locates the standalone server's entrypoint. Packaged builds ship it as an * Locates the standalone server's entrypoint. Packaged builds ship it as an
* extraResource (see electron-builder.config.js) because .next/standalone * extraResource (see electron-builder.config.js) because .next/standalone
@@ -124,6 +161,12 @@ async function startStandaloneServer(): Promise<string> {
PORT: String(port), PORT: String(port),
HOSTNAME: "127.0.0.1", HOSTNAME: "127.0.0.1",
NODE_ENV: process.env.NODE_ENV || "production", NODE_ENV: process.env.NODE_ENV || "production",
// Keep all mutable state out of the .app bundle - see
// getServerDataDirs() for why that matters. Placed after
// ...process.env so the desktop shell's paths win over any inherited
// value; the same standalone server run outside Electron (the Docker
// image) never executes this and keeps its documented env behaviour.
...getServerDataDirs(),
...(encryption.ok ...(encryption.ok
? { VNCMAIL_DESKTOP_STORE_DIR: storeDir, VNCMAIL_DESKTOP_KEY_FD: "3" } ? { VNCMAIL_DESKTOP_STORE_DIR: storeDir, VNCMAIL_DESKTOP_KEY_FD: "3" }
: {}), : {}),
+28
View File
@@ -0,0 +1,28 @@
// electron-builder afterSign hook (mac only - see electron-builder.config.js).
//
// Without a real Apple Developer ID, electron-builder's mac target ships
// with only the auto ad-hoc signature the linker applies to the main
// executable - the rest of the bundle (Resources, Helper.app children,
// frameworks) is left unsigned. That inconsistency is what makes macOS
// report a flat "VNCmail+ is damaged and can't be opened" once the .dmg
// picks up a quarantine attribute (from a browser download, AirDrop, or
// any other trust-boundary crossing) - not the more recoverable
// "unidentified developer, right-click to open anyway" prompt a properly
// (even if only ad-hoc) signed bundle gets. `codesign --deep` here
// produces one consistent signature covering everything, verified against
// the exact failure mode (`codesign --verify --deep --strict` on the
// unsigned-except-linker bundle failed before this was added).
//
// Still not a real Developer ID signature - Gatekeeper will still warn on
// first launch (`spctl` rejects any non-notarized app outright), but as
// the recoverable kind, not the "move to Trash" kind.
const { execFileSync } = require("node:child_process");
module.exports = async function afterSign(context) {
if (context.electronPlatformName !== "darwin") return;
const appPath = `${context.appOutDir}/${context.packager.appInfo.productFilename}.app`;
execFileSync("codesign", ["--force", "--deep", "--sign", "-", appPath], {
stdio: "inherit",
});
};