@rightkit/release 0.2.138 → 0.2.140
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/README.md +16 -0
- package/build-release.mjs +30 -21
- package/cache-policy.mjs +14 -0
- package/committed-source.mjs +2 -1
- package/dmg-build.mjs +10 -3
- package/hdiutil-detach.mjs +42 -0
- package/install-app.mjs +98 -37
- package/macos-notarization.mjs +21 -1
- package/managed-cargo.mjs +4 -1
- package/msix-appinstaller.mjs +24 -0
- package/msix-build.mjs +48 -12
- package/msix-config.mjs +70 -0
- package/msix-plan.mjs +68 -0
- package/msix-target.mjs +28 -0
- package/ownership.mjs +24 -0
- package/package.json +1 -1
- package/preflight.mjs +37 -23
- package/project-type.mjs +43 -0
- package/publish-update.mjs +11 -5
- package/release-invocation.mjs +8 -6
- package/release.mjs +55 -10
- package/rightkit-versions.json +18 -12
- package/source-gate.mjs +21 -0
- package/swift-build-context.mjs +43 -0
package/README.md
CHANGED
|
@@ -35,6 +35,8 @@ right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id d
|
|
|
35
35
|
|
|
36
36
|
It mounts the image read-only and hidden (`-nobrowse`), verifies the app (`codesign --verify --deep --strict`, `spctl --assess`, `stapler validate`, bundle id, and Team ID against the installed copy; a Team ID change is refused) before touching the installed copy, quits the running instance with SIGTERM (15 s, then SIGKILL), moves the old bundle to `~/.Trash/<App> <timestamp>.app`, copies the new one with `ditto` (restoring the old bundle on failure), re-verifies, and relaunches only if it was running or `--relaunch` is given, using `open -g -j -b` (background, hidden, never activated). `--json` emits one lifecycle event per line (`start`, `verified`, `quit`, `replaced`, `relaunched`, `done`/`failed`). Win32 exits with "not implemented on win32". Exit codes: 0 ok, 1 failed, 2 usage, 3 unsupported platform.
|
|
37
37
|
|
|
38
|
+
Development builds (from `@rightkit/git` `devArtifact`, Developer ID-signed but not notarized) install with `--dev` and exactly one of `--dmg`, `--zip <path.zip>` (extracted with `ditto -x -k`), or `--app <path.app>`: `right-release install-app --dev --zip dev-mac.zip`. `--dev` still requires `codesign --verify --deep --strict`, a Team ID, no Team ID change, and a bundle-id match; it skips only `spctl` and `stapler`, removes `com.apple.quarantine` from the new bundle only, prints `DEV BUILD (not notarized)`, and sets `dev: true` on every JSON event.
|
|
39
|
+
|
|
38
40
|
## Entry points
|
|
39
41
|
|
|
40
42
|
Bin: `right-release` (`cli/right-release.mjs`). The package has no `exports` map. The CLI is the supported surface.
|
|
@@ -56,6 +58,20 @@ pnpm add -D @rightkit/release
|
|
|
56
58
|
pnpm exec right-release deps --check
|
|
57
59
|
```
|
|
58
60
|
|
|
61
|
+
## MSIX target and Swift app type
|
|
62
|
+
|
|
63
|
+
Swift (SwiftPM) apps are a first-class project type. `projectType: "swiftpm"` (config or target; auto-detected from a `Package.swift` with no Cargo manifest) replaces the cargo/rustc requirement with `swift`, and skips the Rust target-bridge, sccache and Perl checks. Tauri/Rust targets are unchanged. `makeappx` and `signtool` are required only when `targets.win.msix` is configured.
|
|
64
|
+
|
|
65
|
+
`targets.win` with `signingContract: "windows-msix-signed-v1"` packages an MSIX (schema in `msix-config.mjs`): `prePackage` builds and stages a flat payload (`right-release swift-package windows-stage`), then the msix step runs in the immutable order: verify the declared runtime-DLL inventory and PE import closure (fails closed), sign inner DLLs then EXEs through `sign-windows`, `makeappx pack`, sign the `.msix`, verify the signature and that the signer subject equals `Identity/@Publisher`, write the App Installer route, and write `msix-seal.json`. `msix.virtualization.fileSystemWrite|registryWrite` (`enabled|disabled`) are mandatory and emitted as `desktop6` manifest properties. List the `.msix` under `installer.artifacts` so it seals.
|
|
66
|
+
|
|
67
|
+
`updater.kind: "appinstaller"` (default remains the Tauri minisign flow) generates `msix.appInstaller.out` (https `uri`/`msixUri`); trust is the MSIX Authenticode signature, so no `.sig` is produced and `publish-update` records the sealed package digest.
|
|
68
|
+
|
|
69
|
+
macOS Swift apps use `right-release swift-package mac-app` (Info.plist from config, resources, frameworks, entitlements on the bundle only; nested code signed inside-out before the app).
|
|
70
|
+
|
|
71
|
+
Build a SwiftPM app with `right-release build --platform mac|win` (add `--config <path>` when the config is not `right-release.config.mjs`). Build is tier-neutral and never uploads, so it rejects `--tier` and takes no `--upload`/`--no-upload`; the old `release --tier patch --no-upload` form no longer works. Pick the tier only at upload: `right-release upload --release <sealed-id> --platform mac|win --tier patch|update`.
|
|
72
|
+
|
|
73
|
+
Set `targets.mac.notarize.appBeforePackage: true` (with `notarize.app` naming the `.app`) to notarize and staple the app itself right after signing and before the package step copies it into the DMG, so an app copied out of the DMG passes `stapler validate` offline. The DMG named by `notarize.file` is still notarized and stapled afterward; the app ticket receipt is `macos-app-notarization.json`. Without the flag, behavior is unchanged.
|
|
74
|
+
|
|
59
75
|
## Known issues
|
|
60
76
|
|
|
61
77
|
- `smol-toml` is pinned exactly. A consumer that adds another `smol-toml` gets a second copy (npm audit 2026-10-08, release finding).
|
package/build-release.mjs
CHANGED
|
@@ -33,6 +33,8 @@ import { brokerManagedHost } from "./cargo-contract.mjs";
|
|
|
33
33
|
import { resolveReleaseCacheMode } from "./release-cache-mode.mjs";
|
|
34
34
|
import { resolveTargetRoot } from "./cargo-target.mjs";
|
|
35
35
|
import { assertNsisInPlaceUpgradeContract } from "./nsis-upgrade-contract.mjs";
|
|
36
|
+
import { collectSwiftToolVersions, planSwiftBuild } from "./swift-build-context.mjs";
|
|
37
|
+
import { packageCommandOptional, requiredBuildExecutables, resolveProjectType, targetUsesMsix } from "./project-type.mjs";
|
|
36
38
|
import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
|
|
37
39
|
import { assertCleanSource } from "./source-gate.mjs";
|
|
38
40
|
import { acquireHeavyWorkSlot, heavyCommandEnvironment, summarizePreBrokerWaits, terminateProcessTree } from "./heavy-command.mjs";
|
|
@@ -46,14 +48,14 @@ import { macosCodesignEnvironment } from "./codesign-environment.mjs";
|
|
|
46
48
|
// and test-only edits and would force a cold Rust rebuild of every app each time.
|
|
47
49
|
// Cached target directories must not outlive changes to these signing helpers.
|
|
48
50
|
|
|
49
|
-
const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "sign-macos.mjs", "macos-signing-session.mjs", "codesign-environment.mjs", "codesign-shim.mjs", "codesign-argv.mjs", "osascript-guard.mjs", "dmg-build.mjs", "dmg-layout.mjs", "rightkit-signing-agent.mjs", "notarize-macos.mjs", "macos-notarization.mjs", "signing-receipts.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs"];
|
|
51
|
+
const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "sign-macos.mjs", "macos-signing-session.mjs", "codesign-environment.mjs", "codesign-shim.mjs", "codesign-argv.mjs", "osascript-guard.mjs", "dmg-build.mjs", "dmg-layout.mjs", "rightkit-signing-agent.mjs", "notarize-macos.mjs", "macos-notarization.mjs", "signing-receipts.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs", "msix-build.mjs", "msix-plan.mjs"];
|
|
50
52
|
const PIPELINE_FINGERPRINT = createHash("sha256")
|
|
51
53
|
.update(PIPELINE_FINGERPRINT_SOURCES.map((file) => `${file}:${createHash("sha256").update(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file))).digest("hex")}`).join("\n"))
|
|
52
54
|
.digest("hex")
|
|
53
55
|
.slice(0, 16);
|
|
54
56
|
|
|
55
57
|
/** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
|
|
56
|
-
function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout }) {
|
|
58
|
+
function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout, target }) {
|
|
57
59
|
let version;
|
|
58
60
|
try {
|
|
59
61
|
version = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8")).version;
|
|
@@ -71,6 +73,8 @@ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativ
|
|
|
71
73
|
// reject a legitimate resume of an in-flight build.
|
|
72
74
|
version: undefined,
|
|
73
75
|
cargoLockPaths: nativeLayout?.lockCandidates ?? [],
|
|
76
|
+
projectType: resolveProjectType({ appRoot, config, target }),
|
|
77
|
+
msix: targetUsesMsix(target),
|
|
74
78
|
});
|
|
75
79
|
}
|
|
76
80
|
|
|
@@ -124,7 +128,7 @@ if (dryRun) {
|
|
|
124
128
|
const config = (await import(`${pathToFileURL(configPath).href}?dry-run=${commit}`)).default;
|
|
125
129
|
if (!config?.app || !config?.version) fail("release config must expose app and version");
|
|
126
130
|
const target = config.targets?.[platform];
|
|
127
|
-
if (!target?.package) fail(`${config.app} has no ${platform} package command`);
|
|
131
|
+
if (!target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
|
|
128
132
|
console.log(`right-release build dry-run: ${config.app} ${config.version} ${platform}`);
|
|
129
133
|
console.log(`config: ${configPath}`);
|
|
130
134
|
console.log(`commit: ${commit}`);
|
|
@@ -163,7 +167,8 @@ try {
|
|
|
163
167
|
fail("app and version must be safe release-id components");
|
|
164
168
|
}
|
|
165
169
|
const target = config.targets?.[platform];
|
|
166
|
-
if (!target?.package) fail(`${config.app} has no ${platform} package command`);
|
|
170
|
+
if (!target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
|
|
171
|
+
const isSwiftBuild = resolveProjectType({ appRoot, config, target }) === "swiftpm";
|
|
167
172
|
const nativeLayout = resolveNativeCargoLayout({ appRoot, config });
|
|
168
173
|
if (platform === "win" && target.signingContract === "windows-raw-exe-authenticode-before-nsis-v1") {
|
|
169
174
|
assertNsisInPlaceUpgradeContract(appRoot, target.nsisUpgradeContract);
|
|
@@ -171,7 +176,7 @@ try {
|
|
|
171
176
|
const { requiredInputs, buildInputs } = resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target });
|
|
172
177
|
const dirtyInputs = dirtyBuildInputs(repoRoot, buildInputs, commit);
|
|
173
178
|
if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
|
|
174
|
-
const toolVersions = collectToolVersions(config.packageManager ?? "pnpm");
|
|
179
|
+
const toolVersions = isSwiftBuild ? collectSwiftToolVersions({ packageManager: config.packageManager, exec: commandOutput }) : collectToolVersions(config.packageManager ?? "pnpm");
|
|
175
180
|
const inputHashes = hashInputs(requiredInputs);
|
|
176
181
|
const releaseId = `${config.app}-${config.version}-${shortCommit}`;
|
|
177
182
|
const platformDir = platform === "win" ? "windows" : "mac";
|
|
@@ -208,10 +213,12 @@ try {
|
|
|
208
213
|
// shells take the Cache V2 branch, bridge resolved native target into release cache,
|
|
209
214
|
// and then lose CARGO_TARGET_DIR to the broker's unconditional override — so the
|
|
210
215
|
// app bundle landed in the broker workspace and packaging failed on an empty cache.
|
|
211
|
-
const
|
|
212
|
-
|
|
216
|
+
const swiftPlan = isSwiftBuild ? planSwiftBuild({ appRoot, vaultRoot, platform, target, toolVersions }) : null;
|
|
217
|
+
// SwiftPM targets have no Cargo manifest: no broker cargo target, no Rust cache identity.
|
|
218
|
+
const managedCargoTarget = swiftPlan ? swiftPlan.managedCargoTarget : brokerManagedHost(process.env) ? resolveTargetRoot(cargoToml) : null;
|
|
219
|
+
const cacheIdentity = swiftPlan ? swiftPlan.identity : resolveSharedCacheIdentity({ cargoLockPath: cargoLock, cargoTomlPath: cargoToml, rustcVerbose: toolVersions.rustc, targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? toolVersions.rustHost, profile: target.profile ?? "release" });
|
|
213
220
|
const cacheKey = hashFileText(JSON.stringify({ cache: cacheIdentity.fingerprint, signingIdentity })).slice(0, 16);
|
|
214
|
-
const cacheModeRequested = resolveReleaseCacheMode({ requested: process.env.RIGHT_RELEASE_CACHE_MODE, platform, env: process.env });
|
|
221
|
+
const cacheModeRequested = swiftPlan ? swiftPlan.cacheMode : resolveReleaseCacheMode({ requested: process.env.RIGHT_RELEASE_CACHE_MODE, platform, env: process.env });
|
|
215
222
|
// On a broker-managed host, Cache V2's lease/slot/prune/floor/mark chain is not
|
|
216
223
|
// running against the directory that will actually hold build output — the
|
|
217
224
|
// broker owns CARGO_TARGET_DIR unconditionally (see managedCargoTarget above).
|
|
@@ -233,7 +240,7 @@ try {
|
|
|
233
240
|
// Preflight BEFORE any compile. These preconditions used to be discovered one
|
|
234
241
|
// per build cycle, minutes deep, with errors that named a symptom instead of a
|
|
235
242
|
// cause. Blocking problems stop here, in seconds, naming the fix.
|
|
236
|
-
const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout });
|
|
243
|
+
const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout, target });
|
|
237
244
|
const preflightBlockers = preflightFailures(preflight);
|
|
238
245
|
if (preflightBlockers.length > 0) {
|
|
239
246
|
fail(`preflight found ${preflightBlockers.length} blocking problem(s):\n${formatPreflight(preflightBlockers)}`);
|
|
@@ -252,7 +259,7 @@ try {
|
|
|
252
259
|
// resolves to MSYS Perl, which cannot configure OpenSSL. Put the real Windows
|
|
253
260
|
// Perl first so the build behaves the same from any shell.
|
|
254
261
|
const pathPrefix = buildPathPrefix({ platform });
|
|
255
|
-
const
|
|
262
|
+
const baseEnv = macosCodesignEnvironment({
|
|
256
263
|
...heavyCommandEnvironment(process.env),
|
|
257
264
|
...releaseEnvironment({ root: vaultRoot, cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, cacheKey, kind: "release", appRoot, mode: cacheMode }),
|
|
258
265
|
...(pathPrefix ? { PATH: `${pathPrefix}${path.delimiter}${process.env.PATH ?? ""}` } : {}),
|
|
@@ -261,6 +268,7 @@ try {
|
|
|
261
268
|
RIGHTKIT_MANAGED_ROOT: resolveManagedRoot({ env: process.env }),
|
|
262
269
|
RIGHT_RELEASE_ARTIFACT_DIR: resolveArtifactDir({ app: config.app, version: config.version, managedRoot: resolveManagedRoot({ env: process.env }) }),
|
|
263
270
|
});
|
|
271
|
+
const env = swiftPlan ? swiftPlan.sanitizeEnv(baseEnv) : baseEnv;
|
|
264
272
|
if (managedCargoTarget) {
|
|
265
273
|
for (const name of ["CARGO_HOME", "CARGO_TARGET_DIR", "RUSTC_WRAPPER", "SCCACHE_DIR", "SCCACHE_BASEDIRS", "SCCACHE_CACHE_SIZE", "RUSTFLAGS"]) {
|
|
266
274
|
delete env[name];
|
|
@@ -302,7 +310,7 @@ try {
|
|
|
302
310
|
// ownedRoot is the per-app cache parent holding every fingerprint dir, so a
|
|
303
311
|
// link left by an earlier fingerprint is recognised as ours and repointed
|
|
304
312
|
// instead of hard-stopping the build.
|
|
305
|
-
const targetBridge = createTargetBridge({
|
|
313
|
+
const targetBridge = swiftPlan ? swiftPlan.targetBridge : createTargetBridge({
|
|
306
314
|
link: targetLink,
|
|
307
315
|
target: managedCargoTarget ?? env.CARGO_TARGET_DIR,
|
|
308
316
|
ownedRoot: path.dirname(managedCargoTarget ?? env.CARGO_TARGET_DIR),
|
|
@@ -329,7 +337,7 @@ try {
|
|
|
329
337
|
const volumes = inspectWriteVolumes({ repoRoot, vaultRoot, cacheRoot: sharedCacheRoot });
|
|
330
338
|
assertWriteVolumeFloors({ volumes, policy, configuredRepoMinimumBytes: Math.max(Number(target.preflight?.minFreeGb ?? 25), 25) * 1024 ** 3 });
|
|
331
339
|
} else assertDiskSpace(repoRoot, target.preflight?.minFreeGb ?? 20);
|
|
332
|
-
assertExecutables([
|
|
340
|
+
assertExecutables([...requiredBuildExecutables({ projectType: resolveProjectType({ appRoot, config, target }), packageManager: config.packageManager ?? "pnpm", usesPackageManager: Boolean(config.packageManager) || existsSync(path.join(appRoot, "package.json")) }), ...(target.preflight?.executables ?? [])]);
|
|
333
341
|
for (const name of target.preflight?.env ?? []) if (!process.env[name]) fail(`missing required environment variable: ${name}`);
|
|
334
342
|
for (const command of target.preflight?.commands ?? []) runChecked(command.cmd, command.args ?? [], path.resolve(appRoot, command.cwd ?? "."), env);
|
|
335
343
|
if (buildRoot) mkdirSync(buildRoot, { recursive: true });
|
|
@@ -341,7 +349,7 @@ try {
|
|
|
341
349
|
},
|
|
342
350
|
prepare: async () => {
|
|
343
351
|
throwIfInterrupted();
|
|
344
|
-
const cacheTarget = managedCargoTarget ?? env.CARGO_TARGET_DIR;
|
|
352
|
+
const cacheTarget = swiftPlan ? swiftPlan.buildDir : managedCargoTarget ?? env.CARGO_TARGET_DIR;
|
|
345
353
|
mkdirSync(cacheTarget, { recursive: true });
|
|
346
354
|
// Broker-managed hosts route through the same target bridge as shared
|
|
347
355
|
// mode even though cacheMode is forced to "broker" above: the naive
|
|
@@ -349,12 +357,13 @@ try {
|
|
|
349
357
|
// directory, which is exactly the "corruption" misreading that nearly
|
|
350
358
|
// cost ~5.9 GB of warm broker intermediates. The bridge's brokerManaged
|
|
351
359
|
// flag already knows to leave a real directory untouched instead.
|
|
352
|
-
if (
|
|
360
|
+
if (swiftPlan) { /* SwiftPM: no Rust target link */ }
|
|
361
|
+
else if (cacheMode === "shared" || managedCargoTarget) targetBridge.ensure();
|
|
353
362
|
else if (!existsSync(targetLink)) symlinkSync(cacheTarget, targetLink, process.platform === "win32" ? "junction" : "dir");
|
|
354
363
|
else if (realpathSync(targetLink) !== realpathSync(cacheTarget)) {
|
|
355
364
|
fail(`primary checkout target exists and is not the release cache link: ${targetLink}`);
|
|
356
365
|
}
|
|
357
|
-
await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget, path.join(stateRoot, "stall-install.log"));
|
|
366
|
+
if (resolveProjectType({ appRoot, config, target }) !== "swiftpm" || config.packageManager) await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget, path.join(stateRoot, "stall-install.log"));
|
|
358
367
|
},
|
|
359
368
|
build: async () => {
|
|
360
369
|
throwIfInterrupted();
|
|
@@ -363,9 +372,9 @@ try {
|
|
|
363
372
|
[WORKER, "--config", configPath, "--platform", platform, "--no-upload", "--resume", ...(resumeFrom ? ["--resume-from", resumeFrom] : []), ...(skipChecks ? ["--skip-checks"] : [])],
|
|
364
373
|
appRoot,
|
|
365
374
|
env,
|
|
366
|
-
[managedCargoTarget ?? env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
|
|
375
|
+
swiftPlan ? swiftPlan.watchDirs : [managedCargoTarget ?? env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
|
|
367
376
|
path.join(stateRoot, "stall-build.log"),
|
|
368
|
-
{ livenessOwnerTimeoutMs: target.package
|
|
377
|
+
{ livenessOwnerTimeoutMs: target.package?.livenessOwnerTimeoutMs ?? null },
|
|
369
378
|
);
|
|
370
379
|
checkpoint(stateRoot, "build_complete");
|
|
371
380
|
checkpoint(stateRoot, "signed");
|
|
@@ -461,7 +470,7 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
|
|
|
461
470
|
for (const artifact of target.installer?.artifacts ?? []) addSource(resolveArtifactPath(artifact.file), "installer");
|
|
462
471
|
for (const artifact of target.updater?.artifacts ?? []) {
|
|
463
472
|
addSource(resolveArtifactPath(artifact.file), "updater");
|
|
464
|
-
addSource(resolveArtifactPath(artifact.signature), "updater-signature");
|
|
473
|
+
if (target.updater?.kind !== "appinstaller") addSource(resolveArtifactPath(artifact.signature), "updater-signature");
|
|
465
474
|
}
|
|
466
475
|
for (const file of sources.keys()) if (!existsSync(file)) fail(`cannot seal missing artifact: ${file}`);
|
|
467
476
|
const temp = `${sealedDir}.tmp-${process.pid}`;
|
|
@@ -485,10 +494,10 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
|
|
|
485
494
|
}
|
|
486
495
|
for (const artifact of target.updater?.artifacts ?? []) {
|
|
487
496
|
const source = resolveArtifactPath(artifact.file);
|
|
488
|
-
const signatureSource = resolveArtifactPath(artifact.signature);
|
|
497
|
+
const signatureSource = target.updater?.kind === "appinstaller" ? null : resolveArtifactPath(artifact.signature);
|
|
489
498
|
const patchKey = artifact.patchKey ?? artifact.key.replace("/updates/", "/installers/");
|
|
490
|
-
routes.patch.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(signatureSource), bucket: "public", key: patchKey, platform: artifact.platform });
|
|
491
|
-
routes.update.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(signatureSource), bucket: "private", key: artifact.key, platform: artifact.platform });
|
|
499
|
+
routes.patch.push({ role: "updater", name: sourceNames.get(source), signature: signatureSource ? sourceNames.get(signatureSource) : undefined, bucket: "public", key: patchKey, platform: artifact.platform });
|
|
500
|
+
routes.update.push({ role: "updater", name: sourceNames.get(source), signature: signatureSource ? sourceNames.get(signatureSource) : undefined, bucket: "private", key: artifact.key, platform: artifact.platform });
|
|
492
501
|
}
|
|
493
502
|
writeJson(path.join(temp, "hardening.json"), { schema: 1, passed: true, checkedAt: new Date().toISOString(), files: files.map((file) => file.sha256) });
|
|
494
503
|
const manifest = {
|
package/cache-policy.mjs
CHANGED
|
@@ -518,3 +518,17 @@ function isAbsoluteForPlatform(value) { return isWindowsAbsolute(value) || path.
|
|
|
518
518
|
function resolveForPlatform(value) { return isWindowsAbsolute(value) ? path.win32.resolve(value) : path.posix.resolve(value); }
|
|
519
519
|
function readRustcVerbose() { const result = spawnSync("rustc", ["-vV"], { encoding: "utf8", windowsHide: true }); if (result.status !== 0) throw new Error(`rustc -vV failed: ${(result.stderr || result.error?.message || `exit ${result.status}`).trim()}`); return result.stdout.trim(); }
|
|
520
520
|
function cargoNativeFeatures(cargoTomlPath) { if (!cargoTomlPath || !existsSync(cargoTomlPath)) return []; return [...readFileSync(cargoTomlPath, "utf8").matchAll(/features\s*=\s*\[([^\]]+)\]/g)].flatMap((match) => match[1].match(/"([^"]+)"/g) ?? []).map((value) => value.slice(1, -1)).filter((value) => /sqlcipher|openssl/i.test(value)); }
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* SwiftPM counterpart of resolveSharedCacheIdentity: identity is the Swift toolchain, Package.resolved
|
|
524
|
+
* (dependency pins; absent for dependency-free packages), target architecture and profile. No Rust inputs.
|
|
525
|
+
*/
|
|
526
|
+
export function resolveSwiftCacheIdentity({ swiftVersion, packageResolvedPath, architecture, profile = "release" } = {}) {
|
|
527
|
+
if (!swiftVersion || !String(swiftVersion).trim()) throw new Error("swift --version did not report a toolchain");
|
|
528
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(architecture ?? "")) throw new Error("invalid Swift target architecture");
|
|
529
|
+
const resolved = packageResolvedPath && existsSync(packageResolvedPath) ? readFileSync(packageResolvedPath, "utf8").replace(/\r\n?/g, "\n") : null;
|
|
530
|
+
const packageResolvedSha256 = resolved === null ? "none" : sha256(resolved);
|
|
531
|
+
const swift = String(swiftVersion).trim();
|
|
532
|
+
return { projectType: "swiftpm", packageResolvedSha256, swift, target: architecture, architecture, profile, features: [],
|
|
533
|
+
fingerprint: sha256(JSON.stringify({ projectType: "swiftpm", packageResolvedSha256, swift, architecture, profile })).slice(0, 16) };
|
|
534
|
+
}
|
package/committed-source.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripOwnLockRecords } from "./source-gate.mjs";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -193,7 +194,7 @@ export function assertCommittedCheckoutClean(root, { expectedCommit, expectedTre
|
|
|
193
194
|
const output = runGit(path.resolve(root), ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
194
195
|
encoding: "buffer", maxBuffer: MAX_TREE_BYTES, timeout: COMMAND_TIMEOUT_MS, runner, env: gitEnv(),
|
|
195
196
|
});
|
|
196
|
-
if (output.length) throw new CommittedSourceIncompleteError("committed ownership: protected release checkout has tracked or untracked changes");
|
|
197
|
+
if (stripOwnLockRecords(Buffer.from(output).toString("utf8")).length) throw new CommittedSourceIncompleteError("committed ownership: protected release checkout has tracked or untracked changes");
|
|
197
198
|
return true;
|
|
198
199
|
}
|
|
199
200
|
|
package/dmg-build.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { spawnSync } from "./background-process.mjs";
|
|
5
5
|
import { backgroundAlias, dmgDSStore } from "./dmg-layout.mjs";
|
|
6
|
+
import { detachWithRetrySync } from "./hdiutil-detach.mjs";
|
|
6
7
|
import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
|
|
7
8
|
|
|
8
9
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
@@ -17,7 +18,7 @@ const pair = (value, label, positive = false) => {
|
|
|
17
18
|
export function buildDmg({ app, out, volname, background, iconSize = 128, appPosition = [158, 231],
|
|
18
19
|
applicationsPosition = [492, 231], window = [660, 400], volumeIcon } = {},
|
|
19
20
|
{ platform = process.platform, run = spawnSync, makeAlias = backgroundAlias,
|
|
20
|
-
log = (event) => console.error(JSON.stringify(event)) } = {}) {
|
|
21
|
+
log = (event) => console.error(JSON.stringify(event)), sleep } = {}) {
|
|
21
22
|
if (platform !== "darwin") throw fail("DMG_NATIVE_HOST_REQUIRED", "DMG creation requires macOS");
|
|
22
23
|
if (!app || !out || !volname) throw fail("DMG_INVALID_OPTIONS", "--app, --out & --volname are required");
|
|
23
24
|
app = path.resolve(app); out = path.resolve(out);
|
|
@@ -55,6 +56,12 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
|
|
|
55
56
|
if (result.error || result.status !== 0) throw fail("DMG_COMMAND_FAILED", `${tool} ${args[0]} failed: ${result.error?.message ?? result.stderr ?? result.status}`);
|
|
56
57
|
return result;
|
|
57
58
|
};
|
|
59
|
+
// Detach flakes with "Resource busy": bounded retry with backoff, then -force as the last resort.
|
|
60
|
+
const detach = () => {
|
|
61
|
+
const outcome = detachWithRetrySync({ mount, log, ...(sleep ? { sleep } : {}),
|
|
62
|
+
detach: (args) => run("/usr/bin/hdiutil", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], shell: false, timeout: 300000 }) });
|
|
63
|
+
if (!outcome.ok) throw fail("DMG_COMMAND_FAILED", `hdiutil detach failed after ${outcome.attempts} tries and -force: ${outcome.result?.error?.message ?? outcome.result?.stderr ?? outcome.result?.status}`);
|
|
64
|
+
};
|
|
58
65
|
log({ event: "dmg-build-started", app, out, volname });
|
|
59
66
|
try {
|
|
60
67
|
mkdirSync(stage); mkdirSync(mount);
|
|
@@ -78,7 +85,7 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
|
|
|
78
85
|
const alias = relative ? makeAlias({ mount, volname, relative }) : undefined;
|
|
79
86
|
writeFileSync(path.join(mount, ".DS_Store"), dmgDSStore({ appName: path.basename(app), iconSize,
|
|
80
87
|
appPosition, applicationsPosition, window, alias }));
|
|
81
|
-
|
|
88
|
+
detach(); attached = false;
|
|
82
89
|
command("/usr/bin/hdiutil", ["convert", rw, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", compressed]);
|
|
83
90
|
command("/usr/bin/hdiutil", ["verify", compressed]);
|
|
84
91
|
renameSync(compressed, out);
|
|
@@ -89,7 +96,7 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
|
|
|
89
96
|
throw error;
|
|
90
97
|
} finally {
|
|
91
98
|
if (attached || attaching) {
|
|
92
|
-
try {
|
|
99
|
+
try { detach(); attached = false; attaching = false; }
|
|
93
100
|
catch (error) { log({ event: "dmg-cleanup-failed", mount, work, message: error.message }); }
|
|
94
101
|
}
|
|
95
102
|
// Never recurse through a mount that failed to detach.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Bounded detach for hdiutil mounts. Spotlight, Finder, and quick-look helpers can hold a freshly
|
|
2
|
+
// written volume for a moment, so a first `hdiutil detach` regularly fails with "Resource busy".
|
|
3
|
+
// Retry a fixed number of times with exponential backoff, then fall back to `-force` once.
|
|
4
|
+
|
|
5
|
+
export const DETACH_TRIES = 5;
|
|
6
|
+
export const DETACH_BACKOFF_MS = Object.freeze([500, 1000, 2000, 4000]);
|
|
7
|
+
|
|
8
|
+
const blockingSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
9
|
+
const describe = (result) => String(result?.error?.message ?? result?.stderr ?? result?.status ?? "failed").trim();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Synchronous. `detach(args)` runs `hdiutil <args>` and returns `{ status, stderr?, error? }`.
|
|
13
|
+
* Returns `{ ok, attempts, forced }`; throws only through the caller when `ok` is false.
|
|
14
|
+
*/
|
|
15
|
+
export function detachWithRetrySync({ detach, mount, sleep = blockingSleep, log = () => {}, tries = DETACH_TRIES, backoffMs = DETACH_BACKOFF_MS }) {
|
|
16
|
+
let last;
|
|
17
|
+
for (let attempt = 1; attempt <= tries; attempt += 1) {
|
|
18
|
+
last = detach(["detach", mount]);
|
|
19
|
+
if (!last?.error && last?.status === 0) return { ok: true, attempts: attempt, forced: false, result: last };
|
|
20
|
+
const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)];
|
|
21
|
+
log({ event: "hdiutil-detach-retry", mount, attempt, of: tries, retryInMs: attempt < tries ? wait : 0, message: describe(last) });
|
|
22
|
+
if (attempt < tries) sleep(wait);
|
|
23
|
+
}
|
|
24
|
+
log({ event: "hdiutil-detach-force", mount, message: describe(last) });
|
|
25
|
+
const forced = detach(["detach", "-force", mount]);
|
|
26
|
+
return { ok: !forced?.error && forced?.status === 0, attempts: tries, forced: true, result: forced };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Async twin for promise-based runners: `detach(args)` resolves on success and rejects on failure. */
|
|
30
|
+
export async function detachWithRetry({ detach, mount, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), log = () => {}, tries = DETACH_TRIES, backoffMs = DETACH_BACKOFF_MS }) {
|
|
31
|
+
let last;
|
|
32
|
+
for (let attempt = 1; attempt <= tries; attempt += 1) {
|
|
33
|
+
try { await detach(["detach", mount]); return { ok: true, attempts: attempt, forced: false }; }
|
|
34
|
+
catch (error) { last = error; }
|
|
35
|
+
const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)];
|
|
36
|
+
log({ event: "hdiutil-detach-retry", mount, attempt, of: tries, retryInMs: attempt < tries ? wait : 0, message: String(last?.message ?? last) });
|
|
37
|
+
if (attempt < tries) await sleep(wait);
|
|
38
|
+
}
|
|
39
|
+
log({ event: "hdiutil-detach-force", mount, message: String(last?.message ?? last) });
|
|
40
|
+
try { await detach(["detach", "-force", mount]); return { ok: true, attempts: tries, forced: true }; }
|
|
41
|
+
catch (error) { return { ok: false, attempts: tries, forced: true, error }; }
|
|
42
|
+
}
|
package/install-app.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Sanctioned, non-intrusive local app install from a signed DMG (macOS)
|
|
2
|
+
// Sanctioned, non-intrusive local app install from a signed DMG (macOS); --dev also accepts a
|
|
3
|
+
// Developer ID-signed, not-notarized zip or .app.
|
|
3
4
|
// Never uses a shell, osascript, AppleEvents, or foreground activation.
|
|
4
5
|
import { execFile as nodeExecFile } from "./background-process.mjs";
|
|
6
|
+
import { detachWithRetry } from "./hdiutil-detach.mjs";
|
|
5
7
|
import fs from "node:fs";
|
|
6
8
|
import os from "node:os";
|
|
7
9
|
import path from "node:path";
|
|
@@ -17,11 +19,11 @@ export class InstallAppError extends Error {
|
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
const VALUE_FLAGS = new Set(["--dmg", "--app-name", "--bundle-id", "--dest"]);
|
|
21
|
-
const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json"]);
|
|
22
|
+
const VALUE_FLAGS = new Set(["--dmg", "--zip", "--app", "--app-name", "--bundle-id", "--dest"]);
|
|
23
|
+
const BOOL_FLAGS = new Set(["--relaunch", "--no-relaunch", "--json", "--dev"]);
|
|
22
24
|
|
|
23
25
|
export function parseArgs(argv) {
|
|
24
|
-
const opts = { dmg: null, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false };
|
|
26
|
+
const opts = { dmg: null, zip: null, app: null, dev: false, appName: null, bundleId: null, dest: "/Applications", relaunch: null, json: false };
|
|
25
27
|
for (let i = 0; i < argv.length; i += 1) {
|
|
26
28
|
const flag = argv[i];
|
|
27
29
|
if (VALUE_FLAGS.has(flag)) {
|
|
@@ -29,25 +31,35 @@ export function parseArgs(argv) {
|
|
|
29
31
|
if (value === undefined || value.startsWith("--")) throw new InstallAppError("usage", `${flag} requires a value`);
|
|
30
32
|
i += 1;
|
|
31
33
|
if (flag === "--dmg") opts.dmg = value;
|
|
34
|
+
else if (flag === "--zip") opts.zip = value;
|
|
35
|
+
else if (flag === "--app") opts.app = value;
|
|
32
36
|
else if (flag === "--app-name") opts.appName = value;
|
|
33
37
|
else if (flag === "--bundle-id") opts.bundleId = value;
|
|
34
38
|
else opts.dest = value;
|
|
35
39
|
} else if (BOOL_FLAGS.has(flag)) {
|
|
36
40
|
if (flag === "--json") opts.json = true;
|
|
41
|
+
else if (flag === "--dev") opts.dev = true;
|
|
37
42
|
else opts.relaunch = flag === "--relaunch";
|
|
38
43
|
} else {
|
|
39
44
|
throw new InstallAppError("usage", `unknown argument: ${flag}`);
|
|
40
45
|
}
|
|
41
46
|
}
|
|
42
47
|
if (argv.includes("--relaunch") && argv.includes("--no-relaunch")) throw new InstallAppError("usage", "--relaunch and --no-relaunch are mutually exclusive");
|
|
43
|
-
|
|
44
|
-
if (
|
|
48
|
+
const inputs = [opts.dmg, opts.zip, opts.app].filter((v) => v !== null).length;
|
|
49
|
+
if (inputs === 0) throw new InstallAppError("usage", opts.dev ? "one of --dmg <path.dmg>, --zip <path.zip>, or --app <path.app> is required" : "--dmg <path.dmg> is required");
|
|
50
|
+
if (inputs > 1) throw new InstallAppError("usage", "--dmg, --zip, and --app are mutually exclusive");
|
|
51
|
+
if ((opts.zip !== null || opts.app !== null) && !opts.dev) throw new InstallAppError("usage", "--zip and --app require --dev (only DMGs are installable as release builds)");
|
|
52
|
+
if (opts.dmg !== null && !opts.dmg.toLowerCase().endsWith(".dmg")) throw new InstallAppError("usage", "--dmg must name a .dmg file");
|
|
53
|
+
if (opts.zip !== null && !opts.zip.toLowerCase().endsWith(".zip")) throw new InstallAppError("usage", "--zip must name a .zip file");
|
|
54
|
+
if (opts.app !== null && !opts.app.replace(/\/+$/, "").toLowerCase().endsWith(".app")) throw new InstallAppError("usage", "--app must name a .app bundle");
|
|
45
55
|
if (opts.appName !== null && (!/^[^/\0]+\.app$/.test(opts.appName) || opts.appName === ".app" || opts.appName.includes(".."))) {
|
|
46
56
|
throw new InstallAppError("usage", "--app-name must be a bare bundle name ending in .app");
|
|
47
57
|
}
|
|
48
58
|
if (opts.bundleId !== null && !/^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(opts.bundleId)) throw new InstallAppError("usage", "--bundle-id is not a valid bundle identifier");
|
|
49
59
|
if (!path.isAbsolute(opts.dest)) opts.dest = path.resolve(opts.dest);
|
|
50
|
-
opts.dmg = path.resolve(opts.dmg);
|
|
60
|
+
if (opts.dmg !== null) opts.dmg = path.resolve(opts.dmg);
|
|
61
|
+
if (opts.zip !== null) opts.zip = path.resolve(opts.zip);
|
|
62
|
+
if (opts.app !== null) opts.app = path.resolve(opts.app);
|
|
51
63
|
return opts;
|
|
52
64
|
}
|
|
53
65
|
|
|
@@ -164,9 +176,11 @@ export async function installApp(opts, deps = {}) {
|
|
|
164
176
|
emit = () => {},
|
|
165
177
|
tmpdir = os.tmpdir(),
|
|
166
178
|
} = deps;
|
|
167
|
-
const
|
|
179
|
+
const dev = opts.dev === true;
|
|
180
|
+
const event = (name, data = {}) => emit({ event: name, ...(dev ? { dev: true } : {}), ...data });
|
|
168
181
|
const run = (cmd, args) => execFile(cmd, args);
|
|
169
182
|
let mountRoot = null;
|
|
183
|
+
let extractRoot = null;
|
|
170
184
|
let mounted = false;
|
|
171
185
|
let mountPoint = null;
|
|
172
186
|
let failedStage = "start";
|
|
@@ -174,51 +188,74 @@ export async function installApp(opts, deps = {}) {
|
|
|
174
188
|
const detach = async () => {
|
|
175
189
|
if (!mounted) return;
|
|
176
190
|
mounted = false;
|
|
177
|
-
|
|
178
|
-
catch { try { await run("/usr/bin/hdiutil", ["detach", "-force", mountPoint]); } catch { /* best effort */ } }
|
|
191
|
+
await detachWithRetry({ mount: mountPoint, log: (e) => event(e.event, e), sleep, detach: (args) => run("/usr/bin/hdiutil", args) });
|
|
179
192
|
try { fsImpl.rmSync(mountRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
180
193
|
};
|
|
194
|
+
const cleanupExtract = () => {
|
|
195
|
+
if (!extractRoot) return;
|
|
196
|
+
try { fsImpl.rmSync(extractRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
197
|
+
extractRoot = null;
|
|
198
|
+
};
|
|
181
199
|
|
|
182
200
|
try {
|
|
183
201
|
if (platform !== "darwin") throw new InstallAppError("unsupported", `install-app is not implemented on ${platform}`);
|
|
184
|
-
|
|
185
|
-
|
|
202
|
+
const inputPath = opts.dmg ?? opts.zip ?? opts.app;
|
|
203
|
+
const inputKind = opts.dmg ? "dmg" : opts.zip ? "zip" : "app";
|
|
204
|
+
event("start", { [inputKind]: inputPath, dest: opts.dest });
|
|
205
|
+
if (dev) event("dev-build", { note: "DEV BUILD (not notarized)" });
|
|
206
|
+
if (!fsImpl.existsSync(inputPath)) throw new InstallAppError(`${inputKind}-missing`, `${inputKind.toUpperCase()} not found: ${inputPath}`);
|
|
186
207
|
if (!fsImpl.existsSync(opts.dest)) throw new InstallAppError("dest-missing", `destination not found: ${opts.dest}`);
|
|
187
208
|
|
|
188
|
-
failedStage = "attach";
|
|
189
|
-
mountRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-"));
|
|
190
|
-
const attached = await run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountrandom", mountRoot, "-plist", opts.dmg]);
|
|
191
|
-
const mounts = mountPointsFromAttach(attached.stdout);
|
|
192
|
-
if (mounts.length === 0) throw new InstallAppError("attach", "DMG attached but exposed no mount point");
|
|
193
|
-
mountPoint = mounts[0];
|
|
194
|
-
mounted = true;
|
|
195
|
-
|
|
196
209
|
let sourceApp = null;
|
|
197
|
-
|
|
198
|
-
const apps = fsImpl.readdirSync(
|
|
199
|
-
if (opts.appName)
|
|
200
|
-
|
|
201
|
-
|
|
210
|
+
const pickApp = (dir, label) => {
|
|
211
|
+
const apps = fsImpl.readdirSync(dir).filter((n) => n.endsWith(".app"));
|
|
212
|
+
if (opts.appName) return apps.includes(opts.appName) ? path.join(dir, opts.appName) : null;
|
|
213
|
+
if (apps.length === 1) return path.join(dir, apps[0]);
|
|
214
|
+
if (apps.length > 1) throw new InstallAppError("ambiguous-app", `${label} contains multiple apps (${apps.join(", ")}); pass --app-name`);
|
|
215
|
+
return null;
|
|
216
|
+
};
|
|
217
|
+
if (opts.dmg) {
|
|
218
|
+
failedStage = "attach";
|
|
219
|
+
mountRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-"));
|
|
220
|
+
const attached = await run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountrandom", mountRoot, "-plist", opts.dmg]);
|
|
221
|
+
const mounts = mountPointsFromAttach(attached.stdout);
|
|
222
|
+
if (mounts.length === 0) throw new InstallAppError("attach", "DMG attached but exposed no mount point");
|
|
223
|
+
mountPoint = mounts[0];
|
|
224
|
+
mounted = true;
|
|
225
|
+
for (const mp of mounts) {
|
|
226
|
+
sourceApp = pickApp(mp, "DMG");
|
|
227
|
+
if (sourceApp) break;
|
|
228
|
+
}
|
|
229
|
+
if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in DMG` : "no .app found in DMG");
|
|
230
|
+
} else if (opts.zip) {
|
|
231
|
+
failedStage = "extract";
|
|
232
|
+
extractRoot = fsImpl.mkdtempSync(path.join(tmpdir, "right-install-app-zip-"));
|
|
233
|
+
try { await run("/usr/bin/ditto", ["-x", "-k", opts.zip, extractRoot]); }
|
|
234
|
+
catch (error) { throw new InstallAppError("zip-extract", `ditto -x -k failed for ${opts.zip}: ${errText(error)}`); }
|
|
235
|
+
sourceApp = pickApp(extractRoot, "zip");
|
|
236
|
+
if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in zip` : "no .app found in zip");
|
|
237
|
+
} else {
|
|
238
|
+
sourceApp = opts.app.replace(/\/+$/, "");
|
|
239
|
+
if (opts.appName && path.basename(sourceApp) !== opts.appName) throw new InstallAppError("app-not-found", `--app is ${path.basename(sourceApp)}, not ${opts.appName}`);
|
|
202
240
|
}
|
|
203
|
-
if (!sourceApp) throw new InstallAppError("app-not-found", opts.appName ? `${opts.appName} not found in DMG` : "no .app found in DMG");
|
|
204
241
|
const appName = path.basename(sourceApp);
|
|
205
242
|
const installed = path.join(opts.dest, appName);
|
|
206
243
|
const hadInstalled = fsImpl.existsSync(installed);
|
|
207
244
|
|
|
208
245
|
// Verify the mounted app before touching anything installed.
|
|
209
246
|
failedStage = "verify";
|
|
210
|
-
const newTeam = await verifyBundle(run, sourceApp, { assess:
|
|
247
|
+
const { team: newTeam, ticket } = await verifyBundle(run, sourceApp, { assess: !dev, staple: !dev, dmg: opts.dmg });
|
|
211
248
|
const newBundleId = await readBundleId(run, sourceApp);
|
|
212
|
-
if (opts.bundleId && newBundleId !== opts.bundleId) throw new InstallAppError("bundle-id-mismatch",
|
|
249
|
+
if (opts.bundleId && newBundleId !== opts.bundleId) throw new InstallAppError("bundle-id-mismatch", `${inputKind.toUpperCase()} app bundle id ${newBundleId} != --bundle-id ${opts.bundleId}`);
|
|
213
250
|
let installedTeam = null;
|
|
214
251
|
if (hadInstalled) {
|
|
215
252
|
const installedBundleId = await readBundleId(run, installed);
|
|
216
|
-
if (installedBundleId !== newBundleId) throw new InstallAppError("bundle-id-mismatch",
|
|
253
|
+
if (installedBundleId !== newBundleId) throw new InstallAppError("bundle-id-mismatch", `${inputKind.toUpperCase()} app bundle id ${newBundleId} != installed bundle id ${installedBundleId}`);
|
|
217
254
|
try { installedTeam = await teamIdOf(run, installed); } catch { installedTeam = null; }
|
|
218
|
-
if (installedTeam && installedTeam !== newTeam) throw new InstallAppError("team-id-changed", `refusing team change: installed ${installedTeam},
|
|
255
|
+
if (installedTeam && installedTeam !== newTeam) throw new InstallAppError("team-id-changed", `refusing team change: installed ${installedTeam}, ${inputKind.toUpperCase()} ${newTeam}`);
|
|
219
256
|
}
|
|
220
257
|
const bundleId = opts.bundleId || newBundleId;
|
|
221
|
-
event("verified", { app: appName, bundleId, teamId: newTeam, replacing: hadInstalled });
|
|
258
|
+
event("verified", { app: appName, bundleId, teamId: newTeam, replacing: hadInstalled, ...(ticket ? { ticket } : {}) });
|
|
222
259
|
|
|
223
260
|
// Quit running instance without UI.
|
|
224
261
|
failedStage = "quit";
|
|
@@ -244,11 +281,17 @@ export async function installApp(opts, deps = {}) {
|
|
|
244
281
|
throw new InstallAppError("ditto-failed", `ditto failed (${errText(error)}); ${trashed ? (restored ? "previous app restored" : `RESTORE FAILED, previous app is at ${trashed}`) : "no previous app to restore"}`);
|
|
245
282
|
}
|
|
246
283
|
event("replaced", { installed, previous: trashed });
|
|
284
|
+
// Dev builds are not notarized: clear quarantine on the NEW bundle only so Gatekeeper does not block it.
|
|
285
|
+
if (dev) {
|
|
286
|
+
try { await run("/usr/bin/xattr", ["-dr", "com.apple.quarantine", installed]); }
|
|
287
|
+
catch (error) { event("quarantine-clear-failed", { installed, reason: errText(error) }); }
|
|
288
|
+
}
|
|
247
289
|
await detach();
|
|
290
|
+
cleanupExtract();
|
|
248
291
|
|
|
249
292
|
// Re-verify installed copy.
|
|
250
293
|
failedStage = "reverify";
|
|
251
|
-
await verifyBundle(run, installed, { assess:
|
|
294
|
+
await verifyBundle(run, installed, { assess: !dev, staple: false });
|
|
252
295
|
|
|
253
296
|
// Relaunch hidden and in the background only.
|
|
254
297
|
const relaunch = opts.relaunch === null ? wasRunning : opts.relaunch;
|
|
@@ -257,7 +300,7 @@ export async function installApp(opts, deps = {}) {
|
|
|
257
300
|
await run("/usr/bin/open", ["-g", "-j", "-b", bundleId]);
|
|
258
301
|
event("relaunched", { bundleId });
|
|
259
302
|
}
|
|
260
|
-
const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch };
|
|
303
|
+
const result = { ok: true, app: appName, installed, bundleId, teamId: newTeam, previous: trashed, relaunched: relaunch, ...(dev ? { dev: true } : {}) };
|
|
261
304
|
event("done", result);
|
|
262
305
|
return result;
|
|
263
306
|
} catch (error) {
|
|
@@ -267,10 +310,11 @@ export async function installApp(opts, deps = {}) {
|
|
|
267
310
|
throw error;
|
|
268
311
|
} finally {
|
|
269
312
|
await detach();
|
|
313
|
+
cleanupExtract();
|
|
270
314
|
}
|
|
271
315
|
}
|
|
272
316
|
|
|
273
|
-
async function verifyBundle(run, app, { assess, staple }) {
|
|
317
|
+
async function verifyBundle(run, app, { assess, staple, dmg = null }) {
|
|
274
318
|
const step = async (code, cmd, args) => {
|
|
275
319
|
try { return await run(cmd, args); }
|
|
276
320
|
catch (error) { throw new InstallAppError(code, `${code} failed for ${app}: ${errText(error)}`); }
|
|
@@ -280,8 +324,23 @@ async function verifyBundle(run, app, { assess, staple }) {
|
|
|
280
324
|
if (!team) throw new InstallAppError("team-id-missing", `${app} has no Team ID (ad-hoc or unsigned)`);
|
|
281
325
|
await step("codesign-requirement", "/usr/bin/codesign", ["-dr", "-", app]);
|
|
282
326
|
if (assess) await step("spctl-assess", "/usr/sbin/spctl", ["--assess", "--type", "execute", app]);
|
|
283
|
-
|
|
284
|
-
|
|
327
|
+
let ticket = null;
|
|
328
|
+
if (staple) {
|
|
329
|
+
try { await run("/usr/bin/xcrun", ["stapler", "validate", app]); ticket = "app-ticket"; }
|
|
330
|
+
catch (error) {
|
|
331
|
+
if (!dmg) throw new InstallAppError("stapler-validate", `stapler-validate failed for ${app}: ${errText(error)}`);
|
|
332
|
+
// Standard Tauri flow: the DMG carries the notarization ticket, not the app. Accept only when the
|
|
333
|
+
// stapled DMG validates, its primary signature is accepted, and Gatekeeper reports the app Notarized Developer ID.
|
|
334
|
+
await step("stapler-validate", "/usr/bin/xcrun", ["stapler", "validate", dmg]);
|
|
335
|
+
await step("spctl-dmg", "/usr/sbin/spctl", ["-a", "-t", "open", "--context", "context:primary-signature", dmg]);
|
|
336
|
+
const assessed = await step("spctl-notarized", "/usr/sbin/spctl", ["-a", "-t", "exec", "-vv", app]);
|
|
337
|
+
if (!/Notarized Developer ID/.test(`${assessed?.stderr || ""}\n${assessed?.stdout || ""}`)) {
|
|
338
|
+
throw new InstallAppError("spctl-notarized", `spctl-notarized failed for ${app}: not accepted as Notarized Developer ID`);
|
|
339
|
+
}
|
|
340
|
+
ticket = "dmg-ticket";
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return { team, ticket };
|
|
285
344
|
}
|
|
286
345
|
|
|
287
346
|
async function teamIdOf(run, app) {
|
|
@@ -336,13 +395,15 @@ export async function main(argv = process.argv.slice(2), io = {}) {
|
|
|
336
395
|
try { opts = parseArgs(argv); }
|
|
337
396
|
catch (error) {
|
|
338
397
|
stderr(`right-release install-app: ${error.message}`);
|
|
339
|
-
stderr("usage: right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id dev.orthic.pulse] [--dest /Applications] [--relaunch|--no-relaunch] [--json]");
|
|
398
|
+
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]");
|
|
399
|
+
stderr(" --dev installs a Developer ID-signed, NOT notarized build (skips spctl/stapler, clears quarantine on the new bundle).");
|
|
340
400
|
return 2;
|
|
341
401
|
}
|
|
342
402
|
const emit = (e) => {
|
|
343
403
|
if (opts.json) { stdout(JSON.stringify({ schema: 1, ts: new Date().toISOString(), ...e })); return; }
|
|
344
404
|
const { event, ...rest } = e;
|
|
345
405
|
if (event === "failed") stderr(`install-app: failed at ${rest.stage}: ${rest.reason}`);
|
|
406
|
+
else if (event === "dev-build") stdout(`install-app: ${rest.note}`);
|
|
346
407
|
else stdout(`install-app: ${event}${Object.keys(rest).length ? ` ${JSON.stringify(rest)}` : ""}`);
|
|
347
408
|
};
|
|
348
409
|
try {
|