@rightkit/release 0.2.173 → 0.2.174

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/install-app.mjs CHANGED
@@ -22,11 +22,11 @@ export class InstallAppError extends Error {
22
22
  }
23
23
  }
24
24
 
25
- const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest", "--installer", "--exe"]);
25
+ const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest", "--installer", "--exe", "--payload", "--payload-manifest"]);
26
26
  const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json", "--dev", "--recover"]);
27
27
 
28
28
  export function parseArgs(argv) {
29
- const opts = { dmg: null, zip: null, app: null, installer: null, exe: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false, recover: false };
29
+ const opts = { dmg: null, zip: null, app: null, installer: null, exe: null, payload: [], payloadManifest: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false, recover: false };
30
30
  for (let i = 0; i < argv.length; i += 1) {
31
31
  const flag = argv[i];
32
32
  if (VALUE_FLAGS.has(flag)) {
@@ -40,6 +40,8 @@ export function parseArgs(argv) {
40
40
  else if (flag === "--bundle-id") opts.bundleId = value;
41
41
  else if (flag === "--installer") opts.installer = value;
42
42
  else if (flag === "--exe") opts.exe = value;
43
+ else if (flag === "--payload") opts.payload.push(value);
44
+ else if (flag === "--payload-manifest") opts.payloadManifest = value;
43
45
  else opts.dest = value;
44
46
  } else if (BOOL_FLAGS.has(flag)) {
45
47
  if (flag === "--json") opts.json = true;
@@ -51,11 +53,13 @@ export function parseArgs(argv) {
51
53
  }
52
54
  }
53
55
  if (opts.installer !== null) {
54
- // Windows: run the sealed NSIS installer in place at one fixed folder, keeping <dest>.previous.
56
+ // Windows: run the sealed NSIS installer in place at one fixed folder; rollback is payload-only (--payload / --payload-manifest), never the whole folder.
55
57
  if (opts.dmg !== null || opts.zip !== null || opts.app !== null || opts.recover || opts.dev) throw new InstallAppError("usage", "--installer cannot be combined with --dmg, --zip, --app, --dev or --recover");
56
58
  if (!argv.includes("--dest") || opts.exe === null) throw new InstallAppError("usage", "--installer requires --dest <fixed install folder> and --exe <App.exe>");
59
+ if (opts.payloadManifest !== null) opts.payloadManifest = path.resolve(opts.payloadManifest);
57
60
  return opts;
58
61
  }
62
+ if (opts.payload.length > 0 || opts.payloadManifest !== null) throw new InstallAppError("usage", "--payload and --payload-manifest only apply to --installer (the Windows install)");
59
63
  if (argv.includes("--relaunch") && argv.includes("--no-relaunch")) throw new InstallAppError("usage", "--relaunch and --no-relaunch are mutually exclusive");
60
64
  const inputs = [opts.dmg, opts.zip, opts.app].filter((v) => v !== null).length;
61
65
  if (opts.recover) {
@@ -311,7 +315,8 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
311
315
  let ok = j.phase === "verified";
312
316
  if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
313
317
  if (ok) {
314
- try { await keepPreviousBundle(run, fsImpl, j.staged, j.installed); action = "rolled-forward"; }
318
+ // A dev journal (from an older RightKit) never keeps the old bundle: the staged path is cleared below.
319
+ try { if (!j.dev) await keepPreviousBundle(run, fsImpl, j.staged, j.installed); action = "rolled-forward"; }
315
320
  catch { action = "rolled-forward-previous-pending"; keepJournal = true; keepStaged = true; }
316
321
  } else {
317
322
  swapBack(swapPaths, fsImpl, j.staged, j.installed); // staged now holds the bad new bundle
@@ -325,7 +330,7 @@ export async function recoverInstalls({ dest, appName = null, run, fsImpl, home,
325
330
  let ok = j.phase === "verified";
326
331
  if (!ok) { try { await verifyBundle(run, j.installed, { assess: !j.dev, staple: false }); ok = true; } catch { ok = false; } }
327
332
  if (ok) {
328
- try { await keepPreviousBundle(run, fsImpl, j.backup, j.installed); action = "rolled-forward"; }
333
+ try { if (j.dev) rm(j.backup); else await keepPreviousBundle(run, fsImpl, j.backup, j.installed); action = "rolled-forward"; }
329
334
  catch { action = "rolled-forward-previous-pending"; keepJournal = true; }
330
335
  } else {
331
336
  rm(j.installed);
@@ -384,6 +389,7 @@ export async function installApp(opts, deps = {}) {
384
389
  let mountPoint = null;
385
390
  let failedStage = "start";
386
391
  let swap = null; // set once the journal exists; tracks which renames happened
392
+ let devOldRemoved = false; // a dev install removed the old bundle; nothing can put it back
387
393
 
388
394
  const removeQuiet = (p) => { if (p) { try { fsImpl.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } };
389
395
  // Undo an unfinished swap using renames only. Sync on purpose so a signal handler can run it.
@@ -507,7 +513,8 @@ export async function installApp(opts, deps = {}) {
507
513
  const base = appName.replace(/\.app$/, "");
508
514
  // Staging is never run from: it is verified, then renamed into place. The rollback copy is the fixed `<installed>.previous`.
509
515
  const staged = path.join(opts.dest, `.${base}.rightkit-install-${pid}.app`);
510
- const backup = hadInstalled ? previousPathOf(installed) : null;
516
+ // Dev installs keep NO old copy of any kind (Orthic-Labs/rightkit#348): the old bundle is removed, never moved to `.previous`.
517
+ const backup = hadInstalled && !dev ? previousPathOf(installed) : null;
511
518
  const journal = journalPathFor(opts.dest, appName);
512
519
  const journalData = { schema: 1, appName, pid, phase: "staging", dev, installed, backup, staged, mount: mountPoint, mountRoot };
513
520
  removeQuiet(staged);
@@ -543,6 +550,17 @@ export async function installApp(opts, deps = {}) {
543
550
  writeJournal(fsImpl, journal, { ...jd, phase: "swapping", swap: swapMode });
544
551
  fsImpl.renameSync(staged, installed);
545
552
  swap.stagedIn = true;
553
+ } else if (dev) {
554
+ // Dev: remove the old bundle, then rename the verified staged copy over the fixed path. No backup, no exchange (an exchange
555
+ // would park the old bundle at the staged path). A failure from here leaves the path absent or partial: reinstall from an older DMG.
556
+ swapMode = "replace";
557
+ swap.mode = "two-rename";
558
+ jd = { ...journalData, swap: "two-rename", backup: null };
559
+ writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
560
+ devOldRemoved = true;
561
+ fsImpl.rmSync(installed, { recursive: true, force: true });
562
+ fsImpl.renameSync(staged, installed);
563
+ swap.stagedIn = true;
546
564
  } else {
547
565
  jd = { ...journalData, swap: "atomic", backup: null, stagedIno: fsImpl.statSync(staged).ino };
548
566
  writeJournal(fsImpl, journal, { ...jd, phase: "swapping" });
@@ -586,6 +604,8 @@ export async function installApp(opts, deps = {}) {
586
604
  }
587
605
  if (!oldBundle || kept !== previousPathOf(installed)) { if (!oldBundle) removeQuiet(journal); }
588
606
  else removeQuiet(journal);
607
+ // A stale `.previous` from an older RightKit must not survive a dev install either.
608
+ if (dev) removeQuiet(previousPathOf(installed));
589
609
  swap = null;
590
610
  event("replaced", { installed, previous: kept, swap: swapMode });
591
611
 
@@ -601,7 +621,11 @@ export async function installApp(opts, deps = {}) {
601
621
  return result;
602
622
  } catch (error) {
603
623
  rollbackSwap();
604
- const reason = error instanceof InstallAppError ? error.message : errText(error);
624
+ let reason = error instanceof InstallAppError ? error.message : errText(error);
625
+ if (dev && devOldRemoved) {
626
+ reason = `${reason}; the dev install keeps no old copy, so the failed install was removed: reinstall from an older DMG`;
627
+ if (error instanceof InstallAppError) error.message = reason;
628
+ }
605
629
  event("failed", { stage: failedStage, code: error.code || "error", reason });
606
630
  if (!(error instanceof InstallAppError)) throw new InstallAppError("error", reason);
607
631
  throw error;
@@ -708,7 +732,7 @@ export async function main(argv = process.argv.slice(2), io = {}) {
708
732
  stderr(`right-release install-app: ${error.message}`);
709
733
  stderr("usage: right-release install-app (--dmg <path.dmg> | --dev (--zip <path.zip> | --app <path.app> | --dmg <path.dmg>)) [--app-name Pulse.app] [--bundle-id dev.orthic.pulse] [--dest /Applications] [--relaunch|--no-relaunch] [--json]");
710
734
  stderr(" right-release install-app --recover [--dest /Applications] [--app-name Pulse.app] [--json]");
711
- stderr(" right-release install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--json] (Windows: in place, keeps <dest>.previous)");
735
+ stderr(" right-release install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>] [--json] (Windows: in place; payload-only rollback, never the whole folder)");
712
736
  stderr(" --dev installs a Developer ID-signed, NOT notarized build (skips spctl/stapler, clears quarantine on the new bundle).");
713
737
  return 2;
714
738
  }
@@ -3,9 +3,10 @@
3
3
  //
4
4
  // Pipeline (installTauriDevId): copy the built .app to a private staging copy -> sign nested code, then the app,
5
5
  // with the stable Developer ID (signMacosDevelopmentBundle) -> assert the designated requirement equals the
6
- // installed copy's (assertStableDesignatedRequirement) -> install at the fixed path keeping `<dest>.previous`
7
- // (installApp, dev mode) -> verify the installed copy; any verify failure restores `.previous`.
8
- // resetTauriDevId restores `.previous` or removes the install, only when asked.
6
+ // installed copy's (assertStableDesignatedRequirement) -> install at the fixed path with NO backup of the old copy
7
+ // (installApp, dev mode; Orthic-Labs/rightkit#348) -> verify the installed copy; any verify failure removes the new
8
+ // copy and leaves the path absent (reinstall from an older DMG). Nothing ever creates `<dest>.previous`.
9
+ // resetTauriDevId removes the install, only when asked (mode "remove").
9
10
  //
10
11
  // Every external call (copy, sign, run, install, file system) is injectable through `deps`; the defaults are the
11
12
  // real RightKit pieces. macOS paths are POSIX, so this module uses path.posix throughout.
@@ -14,9 +15,9 @@ import { cpSync, existsSync, mkdtempSync, readFileSync, renameSync, rmSync } fro
14
15
  import os from "node:os";
15
16
  import path from "node:path";
16
17
  import { spawnSync } from "./background-process.mjs";
17
- import { assertStableDesignatedRequirement, signMacosDevelopmentBundle } from "./development-signing.mjs";
18
+ import { assertSignedIdentities, assertStableDesignatedRequirement, readDevSigningSettings, signMacosDevelopmentBundle } from "./development-signing.mjs";
18
19
  import { installApp } from "./install-app.mjs";
19
- import { previousPathOf } from "./in-place-install.mjs";
20
+ import { previousPathOf } from "./in-place-install.mjs"; // only to clear a stale `.previous` an older RightKit left; never to create one
20
21
 
21
22
  export const TAURI_DEVID_KIND = "tauri-devid";
22
23
 
@@ -39,6 +40,7 @@ export function defaultDeps() {
39
40
  run: spawnSync,
40
41
  sign: signMacosDevelopmentBundle,
41
42
  assertRequirement: assertStableDesignatedRequirement,
43
+ assertIdentities: assertSignedIdentities,
42
44
  installApp,
43
45
  exists: existsSync,
44
46
  makeTemp: () => mkdtempSync(P.join(os.tmpdir(), "rightkit-tauri-devid-")),
@@ -50,20 +52,84 @@ export function defaultDeps() {
50
52
  };
51
53
  }
52
54
 
53
- /** The `tauri-devid` descriptor, validated. Accepts the config root or the descriptor itself. */
54
- export function resolveTauriDevIdDescriptor(config, { appRoot = "" } = {}) {
55
+ const invalid = (message) => new TauriDevIdError("TAURI_DEVID_CONFIG_INVALID", message);
56
+ const SAME_IDENTITY_RULE = "dev builds use the SAME install path and bundle id as production (else permission issues), replaced in place with no backup copy; no separate .dev identity";
57
+
58
+ /** The tauri config file for a descriptor: `install.tauriConfig` (contained, relative to the app root) or `tauri.conf.json` beside the manifest. */
59
+ function tauriConfigPath(descriptor, appRoot) {
60
+ if (descriptor.tauriConfig !== undefined) return absolute(appRoot, descriptor.tauriConfig);
61
+ return absolute(appRoot, P.join(P.dirname(descriptor.manifestPath ?? "src-tauri/Cargo.toml"), "tauri.conf.json"));
62
+ }
63
+
64
+ /** `{ productName?, identifier? }` from the tauri config, or null when it cannot be read or parsed. */
65
+ function readTauriConfig(descriptor, appRoot, readFile) {
66
+ try {
67
+ const parsed = JSON.parse(String(readFile(tauriConfigPath(descriptor, appRoot))));
68
+ const pick = (value) => (typeof value === "string" && value ? value : undefined);
69
+ return { productName: pick(parsed?.productName), identifier: pick(parsed?.identifier) };
70
+ } catch { return null; }
71
+ }
72
+
73
+ /**
74
+ * The `tauri-devid` descriptor, validated. Accepts the config root or the descriptor itself.
75
+ * Fail-closed on identity: the destination must be exactly `/Applications/<productName>.app`, where productName is the
76
+ * tauri.conf.json productName (install.productName must equal it) and, when the release config declares
77
+ * `targets.mac.notarize.app`, the basename of that app must agree. There is no other destination.
78
+ * With `readFile` (the install and reset paths) the tauri config must be readable (`tauri.conf.json` beside the manifest, or
79
+ * `install.tauriConfig`) or `install.bundleId` must be set explicitly; without it only the descriptor's shape is checked.
80
+ * `releaseConfig` is the release config root when `config` is the bare descriptor.
81
+ */
82
+ export function resolveTauriDevIdDescriptor(config, { appRoot = "", readFile, releaseConfig } = {}) {
55
83
  const descriptor = config?.development?.targets?.mac?.install ?? config;
56
84
  if (!descriptor || descriptor.kind !== TAURI_DEVID_KIND) throw new TauriDevIdError("TAURI_DEVID_CONFIG_INVALID", `development.targets.mac.install.kind must be "${TAURI_DEVID_KIND}"`);
57
85
  if (typeof descriptor.productName !== "string" || !descriptor.productName) throw new TauriDevIdError("TAURI_DEVID_CONFIG_INVALID", "install.productName is required");
58
86
  if (typeof descriptor.destination !== "string" || !P.isAbsolute(descriptor.destination) || !descriptor.destination.endsWith(".app") || descriptor.destination.split("/").includes("..")) {
59
87
  throw new TauriDevIdError("TAURI_DEVID_CONFIG_INVALID", "install.destination must be an absolute /…/<Product>.app path (the one fixed install path, for example /Applications/<Product>.app)");
60
88
  }
61
- if (descriptor.signing !== undefined) throw new TauriDevIdError("TAURI_DEVID_CONFIG_INVALID", `install.signing is not allowed for ${TAURI_DEVID_KIND}: ad hoc signing changes every build and resets privacy grants`);
89
+ if (descriptor.tauriConfig !== undefined && (typeof descriptor.tauriConfig !== "string" || !descriptor.tauriConfig || P.isAbsolute(descriptor.tauriConfig) || /^[A-Za-z]:/.test(descriptor.tauriConfig) || descriptor.tauriConfig.split(/[\/]/).includes("..") || !/\.json$/i.test(descriptor.tauriConfig))) {
90
+ throw invalid("install.tauriConfig must be a relative path to a .json file inside the app");
91
+ }
92
+ const destination = P.normalize(descriptor.destination);
93
+ const release = releaseConfig ?? (config?.development ? config : undefined);
94
+ const notarizeApp = release?.targets?.mac?.notarize?.app;
95
+ const notarizeName = typeof notarizeApp === "string" && notarizeApp.replaceAll("\\", "/").replace(/\/+$/, "").endsWith(".app") ? P.basename(notarizeApp.replaceAll("\\", "/").replace(/\/+$/, ""), ".app") : undefined;
96
+ if (readFile) {
97
+ const tauri = readTauriConfig(descriptor, appRoot, readFile);
98
+ if (!tauri && descriptor.tauriConfig !== undefined) throw invalid(`install.tauriConfig ${descriptor.tauriConfig} cannot be read or is not JSON; the production identity is read from it`);
99
+ if (!tauri && !descriptor.bundleId) throw invalid("cannot establish the production identity: tauri.conf.json (beside the manifest, or install.tauriConfig) is unreadable and install.bundleId is not set; set either so the install can be checked against it");
100
+ if (tauri?.productName !== undefined && tauri.productName !== descriptor.productName) throw invalid(`install.productName ${JSON.stringify(descriptor.productName)} differs from the tauri.conf.json productName ${JSON.stringify(tauri.productName)}: ${SAME_IDENTITY_RULE}`);
101
+ }
102
+ if (notarizeName !== undefined && notarizeName !== descriptor.productName) throw invalid(`install.productName ${JSON.stringify(descriptor.productName)} disagrees with targets.mac.notarize.app (${notarizeName}.app): ${SAME_IDENTITY_RULE}`);
103
+ if (destination !== `/Applications/${descriptor.productName}.app`) {
104
+ throw invalid(`install.destination ${destination} is not the production install path /Applications/${descriptor.productName}.app: ${SAME_IDENTITY_RULE}`);
105
+ }
106
+ for (const key of ["devIdentity", "refuseSystemApplications"]) {
107
+ if (descriptor[key] !== undefined) throw invalid(`install.${key} is not supported: ${SAME_IDENTITY_RULE}`);
108
+ }
109
+ if (descriptor.signing !== undefined) throw new TauriDevIdError("TAURI_DEVID_CONFIG_INVALID", `install.signing is not allowed for ${TAURI_DEVID_KIND}: ad hoc signing (signing: "-") changes the code identity every build and resets privacy grants; dev builds sign with the same Developer ID as release`);
62
110
  const binaries = descriptor.verifyBinaries ?? [];
63
111
  if (!Array.isArray(binaries) || binaries.some((item) => typeof item !== "string" || !item || P.isAbsolute(item) || item.split("/").includes(".."))) {
64
112
  throw new TauriDevIdError("TAURI_DEVID_CONFIG_INVALID", "install.verifyBinaries must be relative paths inside the app, for example Contents/MacOS/<name>");
65
113
  }
66
- return { ...descriptor, destination: P.normalize(descriptor.destination), verifyBinaries: binaries, appRoot };
114
+ // Shape of the per-binary signing keys (identifiers, identifierTemplate, entitlements, timestamp, assert, assertTeamSignedNested).
115
+ try { readDevSigningSettings(descriptor, config?.development ? config : {}); }
116
+ catch (error) { throw new TauriDevIdError("TAURI_DEVID_CONFIG_INVALID", error.message); }
117
+ return { ...descriptor, destination, verifyBinaries: binaries, appRoot };
118
+ }
119
+
120
+ /** The built .app's CFBundleIdentifier must equal the production identifier (tauri.conf.json, and install.bundleId when set). Fail-closed. */
121
+ function assertBuiltIdentity({ source, descriptor, appRoot, env, deps }) {
122
+ const production = readTauriConfig(descriptor, appRoot, deps.readFile)?.identifier ?? null;
123
+ if (descriptor.bundleId && production && descriptor.bundleId !== production) {
124
+ throw invalid(`install.bundleId ${descriptor.bundleId} differs from the tauri.conf.json identifier ${production}: ${SAME_IDENTITY_RULE}`);
125
+ }
126
+ const expected = production ?? descriptor.bundleId;
127
+ if (!expected) throw invalid(`no production bundle identifier: the tauri.conf.json identifier is unreadable and install.bundleId is not set, so the built app cannot be checked: ${SAME_IDENTITY_RULE}`);
128
+ const read = deps.run("/usr/bin/plutil", ["-extract", "CFBundleIdentifier", "raw", "-o", "-", P.join(source, "Contents", "Info.plist")], { cwd: appRoot, env, encoding: "utf8", windowsHide: true, shell: false });
129
+ const actual = read.error || read.status !== 0 ? null : String(read.stdout ?? "").trim();
130
+ if (actual !== expected) {
131
+ throw new TauriDevIdError("TAURI_DEVID_BUNDLE_ID_MISMATCH", `built ${source} CFBundleIdentifier ${actual === null ? "(unreadable)" : JSON.stringify(actual)} is not the production identifier ${JSON.stringify(expected)}: ${SAME_IDENTITY_RULE}`, { builtBundleId: actual, productionBundleId: expected });
132
+ }
67
133
  }
68
134
 
69
135
  const builtPathOf = (built, appRoot) => {
@@ -74,21 +140,7 @@ const builtPathOf = (built, appRoot) => {
74
140
 
75
141
  const digestOf = (deps, file) => createHash("sha256").update(deps.readFile(file)).digest("hex");
76
142
 
77
- /** Put `.previous` back at the fixed path. Renames only; the bad copy is removed last. */
78
- function restorePrevious(destination, deps) {
79
- const previous = previousPathOf(destination);
80
- if (!deps.exists(previous)) throw new TauriDevIdError("TAURI_DEVID_NO_PREVIOUS", `no rollback copy at ${previous}`);
81
- const aside = `${destination}.reset`;
82
- deps.remove(aside);
83
- const hadCurrent = deps.exists(destination);
84
- if (hadCurrent) deps.rename(destination, aside);
85
- try { deps.rename(previous, destination); }
86
- catch (error) { if (hadCurrent) deps.rename(aside, destination); throw error; }
87
- deps.remove(aside);
88
- return { destination, restoredFrom: previous };
89
- }
90
-
91
- function verifyInstalled({ appRoot, destination, staged, expectedRequirement, binaries, env, deps }) {
143
+ function verifyInstalled({ appRoot, destination, staged, expectedRequirement, binaries, env, deps, descriptor, config, signing }) {
92
144
  const strict = deps.run("codesign", ["--verify", "--strict", "--deep", destination], { cwd: appRoot, env, encoding: "utf8", windowsHide: true, shell: false });
93
145
  if (strict.error || strict.status !== 0) throw new TauriDevIdError("TAURI_DEVID_VERIFY_FAILED", `installed ${destination} failed codesign --verify --strict --deep: ${strict.error?.message ?? text(strict)}`);
94
146
  const display = deps.run("codesign", ["--display", "-r-", destination], { cwd: appRoot, env, encoding: "utf8", windowsHide: true, shell: false });
@@ -96,6 +148,14 @@ function verifyInstalled({ appRoot, destination, staged, expectedRequirement, bi
96
148
  if (display.status !== 0 || actual !== expectedRequirement) {
97
149
  throw new TauriDevIdError("TAURI_DEVID_VERIFY_FAILED", `installed ${destination} designated requirement ${JSON.stringify(actual)} is not the signed ${JSON.stringify(expectedRequirement)}`, { installedRequirement: actual, designatedRequirement: expectedRequirement });
98
150
  }
151
+ // Declared identity assertions (install.assert, install.assertTeamSignedNested) against the installed copy; a failure rolls back.
152
+ const settings = readDevSigningSettings(descriptor, config?.development ? config : {});
153
+ if (settings.assertions.length || settings.assertTeamSignedNested) {
154
+ try {
155
+ deps.assertIdentities({ root: appRoot, bundle: destination, settings, teamIdentifier: signing.teamIdentifier, identity: signing.identity, env, run: deps.run, join: P.join, label: "installed copy",
156
+ ...(deps.listFiles ? { listFiles: deps.listFiles } : {}), ...(deps.isMachO ? { isMachO: deps.isMachO } : {}) });
157
+ } catch (error) { throw new TauriDevIdError("TAURI_DEVID_VERIFY_FAILED", `installed ${destination} failed its identity assertions: ${error.message}`, { problems: error.problems }); }
158
+ }
99
159
  for (const binary of binaries) {
100
160
  let want; let got;
101
161
  try { want = digestOf(deps, P.join(staged, binary)); got = digestOf(deps, P.join(destination, binary)); }
@@ -107,14 +167,16 @@ function verifyInstalled({ appRoot, destination, staged, expectedRequirement, bi
107
167
  /**
108
168
  * @param {{appRoot: string, config: object, built: string|{app: string}, deps?: object, env?: object}} options
109
169
  * `config` is the app's release config (development.targets.mac.install is the `tauri-devid` descriptor).
110
- * @returns {Promise<{installed: string, previous: string|null, designatedRequirement: string, identity: string, teamIdentifier: string, signing: object}>}
170
+ * @returns {Promise<{installed: string, designatedRequirement: string, identity: string, teamIdentifier: string, signing: object}>}
111
171
  */
112
- export async function installTauriDevId({ appRoot, config, built, deps = {}, env = {} }) {
172
+ export async function installTauriDevId({ appRoot, config, built, deps = {}, env = {}, releaseConfig }) {
113
173
  const d = { ...defaultDeps(), ...deps };
114
- const descriptor = resolveTauriDevIdDescriptor(config, { appRoot });
174
+ const descriptor = resolveTauriDevIdDescriptor(config, { appRoot, readFile: d.readFile, releaseConfig });
115
175
  const destination = descriptor.destination;
116
176
  const source = builtPathOf(built, appRoot);
117
177
  if (!d.exists(source)) throw new TauriDevIdError("TAURI_DEVID_BUILT_INVALID", `built app not found: ${source}`);
178
+ // Same identity as production: the built bundle id must be the production identifier, or nothing is copied or signed.
179
+ assertBuiltIdentity({ source, descriptor, appRoot, env, deps: d });
118
180
  const name = P.basename(destination);
119
181
  const tempRoot = d.makeTemp();
120
182
  // The staging copy carries the final bundle name so installApp installs <dest dir>/<name> without renaming.
@@ -122,42 +184,39 @@ export async function installTauriDevId({ appRoot, config, built, deps = {}, env
122
184
  try {
123
185
  d.copyApp(source, staged);
124
186
  // 1. Sign: nested code first, then the app, with the stable Developer ID (development-signing orders it deepest first).
125
- const signing = await d.sign({ root: appRoot, bundle: staged, descriptor, config, env });
187
+ const signing = await d.sign({ root: appRoot, bundle: staged, descriptor, config, env, ...(d.listFiles ? { listFiles: d.listFiles } : {}), ...(d.isMachO ? { isMachO: d.isMachO } : {}) });
126
188
  if (signing.adhoc || !signing.designatedRequirement) throw new TauriDevIdError("TAURI_DEVID_SIGNING_UNSTABLE", `signing produced no stable Developer ID designated requirement for ${staged}; refusing to install`);
127
189
  // 2. Same identity as the installed copy, or fail: a changed designated requirement resets every privacy grant.
128
190
  if (d.exists(destination)) await d.assertRequirement({ root: appRoot, installed: destination, expected: signing.designatedRequirement, env, run: d.run });
129
- // 3. Install at the fixed path; the replaced copy stays as <dest>.previous.
130
- const outcome = await d.installApp({ app: staged, dev: true, dest: P.dirname(destination), appName: name, bundleId: descriptor.bundleId ?? null, relaunch: null }, { env });
131
- // 4. Verify the installed copy; a failure puts .previous back (or removes a first install) so the path is never left broken.
191
+ // 3. Install at the fixed path. The replaced copy is NOT kept anywhere (no <dest>.previous, no backup of any kind).
192
+ await d.installApp({ app: staged, dev: true, dest: P.dirname(destination), appName: name, bundleId: descriptor.bundleId ?? null, relaunch: null }, { env });
193
+ // A stale <dest>.previous from an older RightKit must not survive a dev install either.
194
+ d.remove(previousPathOf(destination));
195
+ // 4. Verify the installed copy; a failure removes the bad copy and leaves the path absent: reinstall from an older DMG.
132
196
  try {
133
- verifyInstalled({ appRoot, destination, staged, expectedRequirement: signing.designatedRequirement, binaries: descriptor.verifyBinaries, env, deps: d });
197
+ verifyInstalled({ appRoot, destination, staged, expectedRequirement: signing.designatedRequirement, binaries: descriptor.verifyBinaries, env, deps: d, descriptor, config, signing });
134
198
  } catch (error) {
135
- let rolledBack = "none";
136
- if (outcome?.previous && d.exists(previousPathOf(destination))) { restorePrevious(destination, d); rolledBack = "restored-previous"; }
137
- else { d.remove(destination); rolledBack = "removed-first-install"; }
138
- error.rolledBack = rolledBack;
199
+ d.remove(destination);
200
+ error.rolledBack = "removed-failed-install";
201
+ error.message = `${error.message}; the failed install was removed and no old copy is kept: reinstall from an older DMG`;
139
202
  throw error;
140
203
  }
141
204
  d.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; designated=${signing.designatedRequirement}`);
142
- return { installed: destination, previous: outcome?.previous ?? null, designatedRequirement: signing.designatedRequirement, identity: signing.identity, teamIdentifier: signing.teamIdentifier, signing };
205
+ return { installed: destination, designatedRequirement: signing.designatedRequirement, identity: signing.identity, teamIdentifier: signing.teamIdentifier, signing };
143
206
  } finally { d.remove(tempRoot); }
144
207
  }
145
208
 
146
209
  /**
147
- * Deliberate reset of a fixed install. mode "restore" (default) makes `.previous` the install again; mode "remove"
148
- * deletes the install and its `.previous` (and, with `resetPrivacy`, runs `tccutil reset All <bundleId>`).
210
+ * Deliberate reset of a fixed install: mode "remove" (required, there is no default) deletes the install and any stale
211
+ * `.previous` (and, with `resetPrivacy`, runs `tccutil reset All <bundleId>`). There is no restore mode: dev installs keep
212
+ * no old copy (Orthic-Labs/rightkit#348), reinstall from an older DMG instead.
149
213
  * Quit the app first; nothing here kills processes.
150
214
  */
151
- export async function resetTauriDevId({ appRoot, config, mode = "restore", resetPrivacy = false, deps = {}, env = {} }) {
215
+ export async function resetTauriDevId({ appRoot, config, mode, resetPrivacy = false, deps = {}, env = {}, releaseConfig }) {
152
216
  const d = { ...defaultDeps(), ...deps };
153
- const descriptor = resolveTauriDevIdDescriptor(config, { appRoot });
217
+ const descriptor = resolveTauriDevIdDescriptor(config, { appRoot, readFile: d.readFile, releaseConfig });
154
218
  const destination = descriptor.destination;
155
- if (mode === "restore") {
156
- const result = restorePrevious(destination, d);
157
- d.log(`right-release dev: restored ${destination} from ${result.restoredFrom}`);
158
- return { mode, ...result };
159
- }
160
- if (mode !== "remove") throw new TauriDevIdError("TAURI_DEVID_RESET_MODE_INVALID", `mode must be "restore" or "remove", got ${JSON.stringify(mode)}`);
219
+ if (mode !== "remove") throw new TauriDevIdError("TAURI_DEVID_RESET_MODE_INVALID", `mode must be "remove" (restore was removed: dev installs keep no old copy), got ${JSON.stringify(mode)}`);
161
220
  d.remove(destination);
162
221
  d.remove(previousPathOf(destination));
163
222
  let privacyReset = false;
@@ -1,5 +1,5 @@
1
1
  // Directory entries (.app bundles) in `sign.prePackageFiles`: sign nested code inside-out first
2
- // (no entitlements), then the bundle itself, reusing the swift-mac-app inside-out discovery.
2
+ // (no entitlements unless declared per path), then the bundle itself, reusing the swift-mac-app inside-out discovery.
3
3
  import { existsSync, readFileSync, statSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { discoverNestedCode } from "./swift-mac-app.mjs";
@@ -31,3 +31,145 @@ export function expandMacSigningEntries(files, { isDirectory = (f) => statSync(f
31
31
  }
32
32
  return { nested: [...new Set(nested)], files };
33
33
  }
34
+
35
+ // ---------------------------------------------------------------------------------------------------------------------
36
+ // Declarative per-binary signing settings, shared by the dev installer (development-signing.mjs) and release nested code.
37
+ //
38
+ // identifiers: { "<bundle-relative path or glob>": "<--identifier value>" }
39
+ // identifierTemplate: "<bundleId>.runtime.<relpath>" (default identifier for the remaining plain Mach-O files)
40
+ // entitlements: { "<bundle-relative path or glob>": "<entitlements plist path>" }
41
+ //
42
+ // Paths are relative to the .app bundle with `/` separators (`Contents/MacOS/engine`); "." names the bundle itself.
43
+ // An exact key beats a glob; among globs the first declared wins. `*` and `?` stay inside one path segment, `**` crosses
44
+ // segments. Nothing declared means nothing changes: no `--identifier`, no entitlements (release nested code stays unentitled).
45
+ // ---------------------------------------------------------------------------------------------------------------------
46
+
47
+ /** A codesign `--identifier` value. Must not start with `-` (argv safety). */
48
+ export const SIGNING_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$/; // same charset as release validation and the signing agent
49
+ const TEMPLATE_TOKENS = new Set(["bundleId", "relpath", "basename"]);
50
+ const BUNDLE_DIRECTORY = /\.(app|framework|xpc|appex|bundle)$/;
51
+ const sanitizePart = (value) => String(value).replace(/[^A-Za-z0-9.-]+/g, ".");
52
+
53
+ export const isGlobPattern = (pattern) => /[*?]/.test(pattern);
54
+
55
+ export function globToRegExp(glob) {
56
+ let out = "";
57
+ for (let i = 0; i < glob.length; i++) {
58
+ const c = glob[i];
59
+ if (c === "*") {
60
+ if (glob[i + 1] === "*") {
61
+ if (glob[i + 2] === "/") { out += "(?:.*/)?"; i += 2; } else { out += ".*"; i += 1; }
62
+ } else out += "[^/]*";
63
+ } else if (c === "?") out += "[^/]";
64
+ else out += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
65
+ }
66
+ return new RegExp(`^${out}$`);
67
+ }
68
+
69
+ /** Bundle-relative POSIX path of `file` inside `app`; "" for the bundle itself. */
70
+ export const bundleRelativePath = (app, file) => path.relative(app, file).split(path.sep).join("/");
71
+
72
+ export function validateBundleRelativeKey(key, label, reject) {
73
+ if (typeof key !== "string" || !key.trim() || key !== key.trim()) reject(`${label}: keys must be non-empty bundle-relative paths`);
74
+ if (key !== "." && (key.startsWith("/") || /^[A-Za-z]:/.test(key) || key.includes("\\") || key.split("/").includes(".."))) {
75
+ reject(`${label}: ${JSON.stringify(key)} must be a path inside the .app bundle (relative, no "..", "/" separators)`);
76
+ }
77
+ }
78
+
79
+ export function validateSigningIdentifier(value, label, reject) {
80
+ if (typeof value !== "string" || !SIGNING_IDENTIFIER_PATTERN.test(value)) reject(`${label} must be a codesign identifier (letters, digits, "." "_" "-"; not starting with "-"), got ${JSON.stringify(value)}`);
81
+ return value;
82
+ }
83
+
84
+ /** `<relpath>` and `<basename>` are the bundle-relative path / file name with runs outside [A-Za-z0-9.-] replaced by ".". */
85
+ export function expandIdentifierTemplate(template, { bundleId, relpath }) {
86
+ return template.replace(/<(bundleId|relpath|basename)>/g, (_, token) => {
87
+ if (token === "bundleId") {
88
+ if (!bundleId) throw fail("identifier template uses <bundleId> but the bundle identifier is unknown");
89
+ return bundleId;
90
+ }
91
+ return sanitizePart(token === "relpath" ? relpath : path.posix.basename(relpath));
92
+ });
93
+ }
94
+
95
+ export function validateIdentifierTemplate(template, label, reject) {
96
+ if (typeof template !== "string" || !template) reject(`${label} must be a non-empty string such as "<bundleId>.runtime.<relpath>"`);
97
+ const tokens = [...template.matchAll(/<([^<>]*)>/g)].map((match) => match[1]);
98
+ if (tokens.some((token) => !TEMPLATE_TOKENS.has(token))) reject(`${label} may only use <bundleId>, <relpath> and <basename>`);
99
+ if (!tokens.includes("relpath") && !tokens.includes("basename")) reject(`${label} must contain <relpath> or <basename> so each binary gets its own identifier`);
100
+ const rest = template.replace(/<[^<>]*>/g, "");
101
+ if (rest.includes("<") || rest.includes(">")) reject(`${label} has an unbalanced "<" or ">"`);
102
+ validateSigningIdentifier(expandIdentifierTemplate(template, { bundleId: "a.b", relpath: "Contents/x" }), `${label} (expanded)`, reject);
103
+ return template;
104
+ }
105
+
106
+ /** Compile `{ pattern: value }` into lookup entries. `value(raw, label)` validates and transforms each value. */
107
+ export function compileBundlePathMap(map, label, { reject, value = (raw) => raw }) {
108
+ if (map === undefined) return [];
109
+ if (!map || typeof map !== "object" || Array.isArray(map)) reject(`${label} must be an object mapping bundle-relative paths or globs to values`);
110
+ return Object.entries(map).map(([pattern, raw]) => {
111
+ validateBundleRelativeKey(pattern, label, reject);
112
+ return { pattern, regex: isGlobPattern(pattern) ? globToRegExp(pattern) : null, value: value(raw, `${label}[${JSON.stringify(pattern)}]`) };
113
+ });
114
+ }
115
+
116
+ /** Exact key first, then the first matching glob. undefined when nothing matches. */
117
+ export function lookupBundlePath(entries, relative) {
118
+ const exact = entries.find((entry) => !entry.regex && entry.pattern === relative);
119
+ if (exact) return exact.value;
120
+ return entries.find((entry) => entry.regex?.test(relative))?.value;
121
+ }
122
+
123
+ /**
124
+ * The `--identifier` for one path, or undefined (codesign then derives it as before). The map applies to anything,
125
+ * including "." for the bundle; the template applies only to plain Mach-O files (`plain`), never to bundles.
126
+ */
127
+ export function resolveBundleIdentifier({ relative, identifiers = [], template, bundleId, plain }) {
128
+ const explicit = lookupBundlePath(identifiers, relative || ".");
129
+ if (explicit !== undefined) return explicit;
130
+ if (!template || !plain || !relative) return undefined;
131
+ const expanded = expandIdentifierTemplate(template, { bundleId, relpath: relative });
132
+ if (!SIGNING_IDENTIFIER_PATTERN.test(expanded)) throw fail(`identifier template produced an invalid identifier for ${relative}: ${JSON.stringify(expanded)}`);
133
+ return expanded;
134
+ }
135
+
136
+ /** CFBundleIdentifier of a bundle (XML plist only; undefined when unreadable). */
137
+ export function bundleIdentifierOf(app) {
138
+ try {
139
+ const plist = readFileSync(path.join(app, "Contents", "Info.plist"), "utf8");
140
+ return /<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/.exec(plist)?.[1];
141
+ } catch { return undefined; }
142
+ }
143
+
144
+ /**
145
+ * Release nested-code settings from `sign.identifiers`, `sign.identifierTemplate` and a per-path `sign.entitlements` object.
146
+ * Returns Map<absolute nested file, { identifier?, entitlements?: absolute }> holding only files with something declared;
147
+ * every other file keeps today's behaviour (no entitlements, no --identifier). A string `sign.entitlements` is the
148
+ * app-level file and is ignored here. Paths in the map are resolved against `root`; a missing file is an error.
149
+ * The caller (release signing) adds the flags with `nestedSigningFlags`.
150
+ */
151
+ export function resolveNestedSigningSettings(nested, { app, sign = {}, bundleId = bundleIdentifierOf(app), root, exists = existsSync }) {
152
+ const reject = (message) => { throw fail(message); };
153
+ const identifiers = compileBundlePathMap(sign.identifiers, "sign.identifiers", { reject, value: (raw, label) => validateSigningIdentifier(raw, label, reject) });
154
+ const template = sign.identifierTemplate === undefined ? undefined : validateIdentifierTemplate(sign.identifierTemplate, "sign.identifierTemplate", reject);
155
+ const perPath = typeof sign.entitlements === "object" && sign.entitlements !== null && !Array.isArray(sign.entitlements) ? sign.entitlements : undefined;
156
+ const entitlements = compileBundlePathMap(perPath, "sign.entitlements", { reject, value: (raw, label) => {
157
+ if (typeof raw !== "string" || !raw.trim()) reject(`${label} must be an entitlements plist path`);
158
+ const file = path.resolve(root, raw);
159
+ if (!exists(file)) reject(`${label} entitlements file not found: ${file}`);
160
+ return file;
161
+ } });
162
+ const settings = new Map();
163
+ if (!identifiers.length && !template && !entitlements.length) return settings;
164
+ for (const file of nested) {
165
+ const relative = bundleRelativePath(app, file);
166
+ const identifier = resolveBundleIdentifier({ relative, identifiers, template, bundleId, plain: !BUNDLE_DIRECTORY.test(file) });
167
+ const entitlementsFile = lookupBundlePath(entitlements, relative);
168
+ if (identifier !== undefined || entitlementsFile !== undefined) {
169
+ settings.set(file, { ...(identifier !== undefined ? { identifier } : {}), ...(entitlementsFile !== undefined ? { entitlements: entitlementsFile } : {}) });
170
+ }
171
+ }
172
+ return settings;
173
+ }
174
+
175
+ export const nestedSigningFlags = (settings) => [...(settings?.identifier ? ["--identifier", settings.identifier] : []), ...(settings?.entitlements ? ["--entitlements", settings.entitlements] : [])];