@rightkit/release 0.2.138 → 0.2.139

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 CHANGED
@@ -56,6 +56,16 @@ pnpm add -D @rightkit/release
56
56
  pnpm exec right-release deps --check
57
57
  ```
58
58
 
59
+ ## MSIX target and Swift app type
60
+
61
+ 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.
62
+
63
+ `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.
64
+
65
+ `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.
66
+
67
+ 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).
68
+
59
69
  ## Known issues
60
70
 
61
71
  - `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 managedCargoTarget = brokerManagedHost(process.env) ? resolveTargetRoot(cargoToml) : null;
212
- const cacheIdentity = resolveSharedCacheIdentity({ cargoLockPath: cargoLock, cargoTomlPath: cargoToml, rustcVerbose: toolVersions.rustc, targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? toolVersions.rustHost, profile: target.profile ?? "release" });
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 env = macosCodesignEnvironment({
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(["git", config.packageManager ?? "pnpm", "cargo", "rustc", ...(target.preflight?.executables ?? [])]);
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 (cacheMode === "shared" || managedCargoTarget) targetBridge.ensure();
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.livenessOwnerTimeoutMs ?? null },
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/managed-cargo.mjs CHANGED
@@ -56,7 +56,10 @@ export function runCargoSync(args, options = {}, { platform = process.platform,
56
56
  }
57
57
  return result;
58
58
  }
59
- if (platform === 'darwin' || (managedHost(env) && platform === 'win32')) return unavailable('installed RightKit runtime client is unavailable for managed Cargo metadata');
59
+ // The Mac never falls back to direct Cargo, except on GitHub Actions runners, which have no
60
+ // RightKit runtime and use the toolchain's cargo (release-candidate signing jobs).
61
+ const hostedCi = env.GITHUB_ACTIONS === 'true' && !managedHost(env);
62
+ if ((platform === 'darwin' && !hostedCi) || (managedHost(env) && platform === 'win32')) return unavailable('installed RightKit runtime client is unavailable for managed Cargo metadata');
60
63
  return spawn("cargo", args, options);
61
64
  }
62
65
 
@@ -0,0 +1,24 @@
1
+ // App Installer (.appinstaller) update route for MSIX packages. Trust comes from the
2
+ // MSIX Authenticode signature (publisher must match the manifest); no separate updater key.
3
+
4
+ const xml = (value) => String(value).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]);
5
+ const fail = (message) => Object.assign(new Error(message), { code: "APPINSTALLER_INVALID" });
6
+
7
+ export function generateAppInstaller(o) {
8
+ const errors = [];
9
+ for (const key of ["uri", "msixUri"]) { try { if (new URL(o[key]).protocol !== "https:") throw 0; } catch { errors.push(`${key} must be an https URL`); } }
10
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(o.version ?? "")) errors.push("version must be four numeric parts");
11
+ if (!["x64", "arm64"].includes(o.architecture)) errors.push("architecture must be x64 or arm64");
12
+ if (!o.name || !o.publisher) errors.push("name and publisher are required");
13
+ const hours = o.hoursBetweenUpdateChecks ?? 24;
14
+ if (!Number.isInteger(hours) || hours < 0 || hours > 255) errors.push("hoursBetweenUpdateChecks must be an integer 0-255");
15
+ if (errors.length) throw fail(errors.join("; "));
16
+ return `<?xml version="1.0" encoding="utf-8"?>
17
+ <AppInstaller xmlns="http://schemas.microsoft.com/appx/appinstaller/2018" Version="${xml(o.version)}" Uri="${xml(o.uri)}">
18
+ <MainPackage Name="${xml(o.name)}" Publisher="${xml(o.publisher)}" Version="${xml(o.version)}" ProcessorArchitecture="${o.architecture}" Uri="${xml(o.msixUri)}" />
19
+ <UpdateSettings>
20
+ <OnLaunch HoursBetweenUpdateChecks="${hours}" ShowPrompt="${o.showPrompt ? "true" : "false"}" UpdateBlocksActivation="${o.updateBlocksActivation ? "true" : "false"}" />
21
+ </UpdateSettings>
22
+ </AppInstaller>
23
+ `;
24
+ }
package/msix-build.mjs CHANGED
@@ -9,6 +9,9 @@ import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { spawnSync } from "./background-process.mjs";
11
11
  import { findWindowsSdkTool } from "./preflight.mjs";
12
+ import { generateAppInstaller } from "./msix-appinstaller.mjs";
13
+ import { assertPublisherMatchesSigner, planMsixSigning, verifyPayloadInventory } from "./msix-plan.mjs";
14
+ import { createHash } from "node:crypto";
12
15
 
13
16
  const fail = (code, message) => Object.assign(new Error(message), { code });
14
17
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -33,17 +36,25 @@ export function generateAppxManifest(o) {
33
36
  validateMsixIdentity(o);
34
37
  const deps = (o.dependencies ?? []).filter((d) => d.minVersion).map((d) =>
35
38
  ` <PackageDependency Name="${xml(d.name)}" MinVersion="${xml(d.minVersion)}" Publisher="${xml(d.publisher ?? "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US")}" />`);
36
- const a = o.assets ?? {};
39
+ const v = o.virtualization;
40
+ for (const key of ["fileSystemWrite", "registryWrite"]) {
41
+ if (v?.[key] !== undefined && !["enabled", "disabled"].includes(v[key])) throw fail("MSIX_INVALID_IDENTITY", `virtualization.${key} must be "enabled" or "disabled"`);
42
+ }
43
+ const virt = v ? [["fileSystemWrite", "FileSystemWriteVirtualization"], ["registryWrite", "RegistryWriteVirtualization"]]
44
+ .filter(([k]) => v[k]).map(([k, el]) => `\n <desktop6:${el}>${v[k]}</desktop6:${el}>`).join("") : "";
45
+ // Opting out of virtualization is a restricted capability.
46
+ const unvirtualized = v && (v.fileSystemWrite === "disabled" || v.registryWrite === "disabled") ? '\n <rescap:Capability Name="unvirtualizedResources" />' : "";
37
47
  return `<?xml version="1.0" encoding="utf-8"?>
38
48
  <Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
39
49
  xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
40
50
  xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
51
+ xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
41
52
  IgnorableNamespaces="uap rescap">
42
53
  <Identity Name="${xml(o.name)}" Publisher="${xml(o.publisher)}" Version="${xml(o.version)}" ProcessorArchitecture="${o.architecture}" />
43
54
  <Properties>
44
55
  <DisplayName>${xml(o.displayName)}</DisplayName>
45
56
  <PublisherDisplayName>${xml(o.publisherDisplayName)}</PublisherDisplayName>
46
- <Logo>Assets\\StoreLogo.png</Logo>
57
+ <Logo>Assets\\StoreLogo.png</Logo>${virt}
47
58
  </Properties>
48
59
  <Dependencies>
49
60
  <TargetDeviceFamily Name="Windows.Desktop" MinVersion="${xml(o.minWindowsVersion ?? "10.0.19041.0")}" MaxVersionTested="${xml(o.maxWindowsVersionTested ?? "10.0.26100.0")}" />
@@ -56,7 +67,7 @@ ${deps.join("\n")}${deps.length ? "\n" : ""} </Dependencies>
56
67
  </Application>
57
68
  </Applications>
58
69
  <Capabilities>
59
- <rescap:Capability Name="runFullTrust" />${(o.capabilities ?? []).map((c) => `\n <Capability Name="${xml(c)}" />`).join("")}
70
+ <rescap:Capability Name="runFullTrust" />${unvirtualized}${(o.capabilities ?? []).map((c) => `\n <Capability Name="${xml(c)}" />`).join("")}
60
71
  </Capabilities>
61
72
  </Package>
62
73
  `;
@@ -89,19 +100,30 @@ export function peFilesIn(root) {
89
100
  return readdirSync(root).filter((name) => /\.(exe|dll)$/i.test(name)).sort().map((name) => path.join(root, name));
90
101
  }
91
102
 
103
+ const sha256 = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
104
+
105
+ function readSignerSubject(file, run) {
106
+ const r = run("powershell", ["-NoProfile", "-Command", `(Get-AuthenticodeSignature -LiteralPath '${file.replace(/'/g, "''")}').SignerCertificate.Subject`], { encoding: "utf8", windowsHide: true });
107
+ return r.status === 0 ? String(r.stdout ?? "").trim() : "";
108
+ }
109
+
92
110
  /**
93
- * Pack + sign. `run` is injectable for tests. Steps:
94
- * 1 sign every PE file in the root (raw bytes), 2 makeappx pack, 3 sign the .msix, 4 verify.
95
- * Windows-only unless dryRun.
111
+ * Pack + sign. `run` is injectable for tests. Ordered per planMsixSigning():
112
+ * assemble, verify-inventory, sign-pe (DLLs then EXEs, raw bytes), pack, sign-msix, verify-msix,
113
+ * verify-publisher, appinstaller, seal. Windows-only unless dryRun.
114
+ * options.runtime = { dlls, systemDlls, dependencies } (declared inventory, verified fail-closed);
115
+ * options.appInstaller = { uri, msixUri, out, ... } generates the update route after the package is signed.
96
116
  */
97
- export function buildMsix({ stageDir, out, workDir, manifest, assets, sign = true, dryRun = false, receiptDir },
98
- { platform = process.platform, run = spawnSync, findTool = findWindowsSdkTool, env = process.env, log = (e) => console.error(JSON.stringify(e)) } = {}) {
117
+ export function buildMsix({ stageDir, out, workDir, manifest, assets, runtime, appInstaller, sign = true, dryRun = false, receiptDir, peReceipt, msixReceipt },
118
+ { platform = process.platform, run = spawnSync, findTool = findWindowsSdkTool, env = process.env, log = (e) => console.error(JSON.stringify(e)), readSubject = (file) => readSignerSubject(file, run) } = {}) {
99
119
  out = path.resolve(out);
100
120
  if (!out.endsWith(".msix")) throw fail("MSIX_INVALID_OPTIONS", "out must end with .msix");
101
121
  validateMsixIdentity(manifest);
102
122
  workDir = path.resolve(workDir ?? `${out}.root`);
103
123
  const receipts = path.resolve(receiptDir ?? path.dirname(out));
104
- const steps = ["assemble", ...(sign ? ["sign-pe"] : []), "pack", ...(sign ? ["sign-msix", "verify-msix"] : [])];
124
+ const planFor = (files) => planMsixSigning({ files, sign, appInstaller: Boolean(appInstaller) });
125
+ const staged = existsSync(path.resolve(stageDir)) ? readdirSync(path.resolve(stageDir)) : [];
126
+ const steps = planFor(staged.filter((n) => /\.(exe|dll)$/i.test(n))).map((p) => p.step);
105
127
  if (dryRun) { log({ event: "msix-dry-run", out, steps }); return { out, steps, dryRun: true }; }
106
128
  if (platform !== "win32") throw fail("MSIX_NATIVE_HOST_REQUIRED", "MSIX packing and signing must run on Windows");
107
129
  const makeappx = env.MAKEAPPX_PATH || findTool("makeappx.exe")?.toolPath;
@@ -113,16 +135,30 @@ export function buildMsix({ stageDir, out, workDir, manifest, assets, sign = tru
113
135
  };
114
136
  const signer = (files, receipt) => checked(process.execPath, [path.join(here, "sign-windows.mjs"), "--receipt", receipt, ...files]);
115
137
  const root = assembleMsixRoot({ stageDir, root: workDir, manifest, assets });
138
+ const inventory = runtime ? verifyPayloadInventory({ root, inventory: runtime, architecture: manifest.architecture }) : null;
116
139
  mkdirSync(receipts, { recursive: true });
117
- if (sign) signer(peFilesIn(root), path.join(receipts, "msix-pe-sign.receipt.json"));
140
+ const ordered = planFor(readdirSync(root).filter((n) => /\.(exe|dll)$/i.test(n))).find((p) => p.step === "sign-pe")?.files ?? [];
141
+ if (sign) signer(ordered.map((f) => path.join(root, path.basename(f))), peReceipt ?? path.join(receipts, "msix-pe-sign.receipt.json"));
118
142
  mkdirSync(path.dirname(out), { recursive: true });
119
143
  rmSync(out, { force: true });
120
144
  checked(makeappx, ["pack", "/d", root, "/p", out, "/o"]);
121
145
  if (!existsSync(out) || statSync(out).size === 0) throw fail("MSIX_PACK_EMPTY", `makeappx produced no package: ${out}`);
122
146
  if (sign) {
123
- signer([out], path.join(receipts, "msix-sign.receipt.json"));
147
+ signer([out], msixReceipt ?? path.join(receipts, "msix-sign.receipt.json"));
124
148
  checked(process.execPath, [path.join(here, "sign-windows.mjs"), "--verify-only", out]);
149
+ assertPublisherMatchesSigner(manifest.publisher, readSubject(out));
150
+ }
151
+ let appInstallerFile = null;
152
+ if (appInstaller) {
153
+ appInstallerFile = path.resolve(appInstaller.out);
154
+ mkdirSync(path.dirname(appInstallerFile), { recursive: true });
155
+ writeFileSync(appInstallerFile, generateAppInstaller({ ...appInstaller, name: manifest.name, publisher: manifest.publisher, version: manifest.version, architecture: manifest.architecture }));
125
156
  }
157
+ const seal = { schema: 1, msix: { file: out, sha256: sha256(out), sizeBytes: statSync(out).size }, signed: sign, identity: { name: manifest.name, version: manifest.version, publisher: manifest.publisher, architecture: manifest.architecture },
158
+ payload: readdirSync(root, { recursive: true }).map((n) => String(n).split(path.sep).join("/")).filter((n) => statSync(path.join(root, n)).isFile()).sort().map((n) => ({ name: n, sha256: sha256(path.join(root, n)) })), inventory,
159
+ appInstaller: appInstallerFile && { file: appInstallerFile, sha256: sha256(appInstallerFile) } };
160
+ const sealFile = path.join(receipts, "msix-seal.json");
161
+ writeFileSync(sealFile, `${JSON.stringify(seal, null, 2)}\n`);
126
162
  log({ event: "msix-built", out, signed: sign });
127
- return { out, steps, root, signed: sign };
163
+ return { out, steps, root, signed: sign, seal: sealFile, appInstaller: appInstallerFile };
128
164
  }
@@ -0,0 +1,70 @@
1
+ // `msix` target schema for right-release configs (targets.win.msix) and the
2
+ // `windows-msix-signed-v1` signing contract. Pure validation; no I/O.
3
+ //
4
+ // targets.win = {
5
+ // signed: true, signingContract: "windows-msix-signed-v1",
6
+ // projectType: "swiftpm", // optional; auto-detected from Package.swift
7
+ // prePackage: { cmd, args }, // SwiftPM build + stage (swift-package windows-stage)
8
+ // msix: {
9
+ // stageDir, out, // flat payload dir produced by prePackage; .msix path
10
+ // identity: { name, publisher, publisherDisplayName, displayName, description?, version, architecture, executable },
11
+ // capabilities: ["internetClient"], minWindowsVersion?, maxWindowsVersionTested?,
12
+ // virtualization: { fileSystemWrite: "enabled|disabled", registryWrite: "enabled|disabled" }, // explicit, required
13
+ // assets: { storeLogo, square150, square44 },
14
+ // runtime: { dlls: ["swiftCore.dll", ...], systemDlls?: [], dependencies?: [{ name, minVersion, provides: [] }] },
15
+ // appInstaller?: { uri, msixUri, out, hoursBetweenUpdateChecks?, showPrompt?, updateBlocksActivation? },
16
+ // },
17
+ // updater?: { kind: "tauri" | "appinstaller", artifacts: [...] }, // tauri stays the default
18
+ // installer: { artifacts: [{ file: "<out>.msix", key }] },
19
+ // }
20
+
21
+ export const MSIX_SIGNING_CONTRACT = "windows-msix-signed-v1";
22
+ export const UPDATER_KINDS = Object.freeze(["tauri", "appinstaller"]);
23
+ export const VIRTUALIZATION_VALUES = Object.freeze(["enabled", "disabled"]);
24
+
25
+ export const isMsixTarget = (target) => target?.signingContract === MSIX_SIGNING_CONTRACT;
26
+ export const updaterKind = (target) => target?.updater?.kind ?? "tauri";
27
+
28
+ const isHttps = (value) => { try { return new URL(value).protocol === "https:"; } catch { return false; } };
29
+
30
+ /** Returns an array of human-readable problems (empty when valid). */
31
+ export function validateMsixTarget(target, app = "app") {
32
+ const e = [];
33
+ const at = (message) => e.push(`${app} win msix: ${message}`);
34
+ if (!isMsixTarget(target)) at(`signingContract must be ${MSIX_SIGNING_CONTRACT}`);
35
+ const m = target?.msix;
36
+ if (!m || typeof m !== "object") { at("msix block is required"); return e; }
37
+ if (!target.prePackage?.cmd) at("prePackage is required (SwiftPM build + payload staging)");
38
+ if (target.sign?.prePackageFiles?.length || target.sign?.files?.length) at("omit sign.prePackageFiles/sign.files: the msix step signs payload PE files, then the package");
39
+ if (!m.stageDir) at("msix.stageDir is required");
40
+ if (!/\.msix$/i.test(m.out ?? "")) at("msix.out must end with .msix");
41
+ const id = m.identity ?? {};
42
+ for (const key of ["name", "publisher", "publisherDisplayName", "displayName", "version", "architecture", "executable"]) if (!id[key]) at(`msix.identity.${key} is required`);
43
+ const v = m.virtualization ?? {};
44
+ for (const key of ["fileSystemWrite", "registryWrite"]) {
45
+ if (!VIRTUALIZATION_VALUES.includes(v[key])) at(`msix.virtualization.${key} must be explicitly "enabled" or "disabled"`);
46
+ }
47
+ for (const key of ["storeLogo", "square150", "square44"]) if (!m.assets?.[key]) at(`msix.assets.${key} (PNG) is required`);
48
+ const dlls = m.runtime?.dlls;
49
+ if (!Array.isArray(dlls)) at("msix.runtime.dlls (explicit runtime dependency inventory; use [] only for a dependency-free app) is required");
50
+ else if (dlls.some((d) => !/\.dll$/i.test(d) || /[\\/]/.test(d))) at("msix.runtime.dlls entries must be flat .dll names");
51
+ const kind = updaterKind(target);
52
+ if (!UPDATER_KINDS.includes(kind)) at(`updater.kind must be one of ${UPDATER_KINDS.join(", ")}`);
53
+ if (kind === "appinstaller") {
54
+ const a = m.appInstaller;
55
+ if (!a) at("updater.kind appinstaller requires msix.appInstaller");
56
+ else {
57
+ if (!isHttps(a.uri)) at("msix.appInstaller.uri must be an https URL");
58
+ if (!isHttps(a.msixUri)) at("msix.appInstaller.msixUri must be an https URL");
59
+ if (!/\.appinstaller$/i.test(a.out ?? "")) at("msix.appInstaller.out must end with .appinstaller");
60
+ }
61
+ }
62
+ const outName = String(m.out ?? "").split(/[\\/]/).pop();
63
+ if (outName && !(target.installer?.artifacts ?? []).some((a) => String(a.file).split(/[\\/]/).pop() === outName)) at("installer.artifacts must list the .msix so it is sealed");
64
+ return e;
65
+ }
66
+
67
+ export function assertValidMsixTarget(target, app) {
68
+ const errors = validateMsixTarget(target, app);
69
+ if (errors.length) throw Object.assign(new Error(errors.join("; ")), { code: "MSIX_CONFIG_INVALID" });
70
+ }
package/msix-plan.mjs ADDED
@@ -0,0 +1,68 @@
1
+ // Pure planning/verification helpers for MSIX packaging: signing order, runtime
2
+ // dependency inventory, publisher/signer match. No signing, no process spawns.
3
+
4
+ import { readFileSync, readdirSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { readPeImports } from "./pe-imports.mjs";
7
+ import { WINDOWS_SYSTEM_DLLS } from "./swift-windows-stage.mjs";
8
+
9
+ const fail = (code, message) => Object.assign(new Error(message), { code });
10
+ const lower = (v) => String(v).toLowerCase();
11
+ const isApiSet = (name) => /^(api|ext)-ms-win-[a-z0-9-]+-l\d+-\d+-\d+\.dll$/.test(name);
12
+
13
+ /**
14
+ * Immutable ordering (docs/rules/release-signing.md): inner PE files (DLLs, then EXEs) are signed
15
+ * before packing, the package is signed after, verification follows, then seal. Returns step list.
16
+ */
17
+ export function planMsixSigning({ files, sign = true, appInstaller = false }) {
18
+ const pe = files.filter((f) => /\.(dll|exe)$/i.test(f));
19
+ const rank = (f) => (/\.dll$/i.test(f) ? 0 : 1);
20
+ const ordered = [...pe].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
21
+ return [
22
+ { step: "assemble" },
23
+ { step: "verify-inventory" },
24
+ ...(sign ? [{ step: "sign-pe", files: ordered }] : []),
25
+ { step: "pack" },
26
+ ...(sign ? [{ step: "sign-msix" }, { step: "verify-msix" }, { step: "verify-publisher" }] : []),
27
+ ...(appInstaller ? [{ step: "appinstaller" }] : []),
28
+ { step: "seal" },
29
+ ];
30
+ }
31
+
32
+ /**
33
+ * Fail closed unless (a) every DLL in the declared inventory is in the payload and (b) every PE in the
34
+ * payload imports only payload files, Windows system DLLs/API sets, or DLLs provided by a declared
35
+ * package dependency. Equivalent to `dumpbin /dependents` closure, but host independent.
36
+ */
37
+ export function verifyPayloadInventory({ root, inventory = {}, architecture }) {
38
+ const names = readdirSync(root);
39
+ const present = new Set(names.map(lower));
40
+ const problems = [];
41
+ for (const dll of inventory.dlls ?? []) if (!present.has(lower(dll))) problems.push(`declared runtime DLL missing from payload: ${dll}`);
42
+ const system = new Set([...WINDOWS_SYSTEM_DLLS, ...(inventory.systemDlls ?? [])].map(lower));
43
+ const provided = new Set((inventory.dependencies ?? []).flatMap((d) => d.provides ?? []).map(lower));
44
+ const observed = new Set();
45
+ for (const name of names.filter((n) => /\.(exe|dll)$/i.test(n))) {
46
+ const info = readPeImports(readFileSync(path.join(root, name)));
47
+ if (architecture && info.architecture !== architecture) problems.push(`${name} is ${info.architecture}, package is ${architecture}`);
48
+ for (const dep of [...info.imports, ...info.delayImports]) {
49
+ const key = lower(dep);
50
+ observed.add(key);
51
+ if (!present.has(key) && !system.has(key) && !isApiSet(key) && !provided.has(key)) problems.push(`${name} imports ${dep}, which is neither in the payload, a system DLL, nor provided by a declared dependency`);
52
+ }
53
+ }
54
+ // Declared-but-never-imported DLLs are reported (not fatal): they may be loaded dynamically.
55
+ const unreferenced = (inventory.dlls ?? []).filter((d) => !observed.has(lower(d)));
56
+ if (problems.length) throw fail("MSIX_INVENTORY_INCOMPLETE", problems.join("; "));
57
+ return { dlls: [...(inventory.dlls ?? [])], unreferenced };
58
+ }
59
+
60
+ const normalizeDn = (dn) => String(dn).split(/,(?=\s*[A-Za-z]+=)/).map((p) => p.trim().replace(/\s*=\s*/, "=")).sort().join(",");
61
+
62
+ /** The package signer's certificate subject must equal Identity/@Publisher or Windows refuses to install. */
63
+ export function assertPublisherMatchesSigner(publisher, signerSubject) {
64
+ if (!signerSubject) throw fail("MSIX_PUBLISHER_MISMATCH", "could not read the signer certificate subject from the signed package");
65
+ if (normalizeDn(publisher) !== normalizeDn(signerSubject)) {
66
+ throw fail("MSIX_PUBLISHER_MISMATCH", `manifest Publisher "${publisher}" does not equal signer subject "${signerSubject}"; set identity.publisher to the signer subject`);
67
+ }
68
+ }
@@ -0,0 +1,28 @@
1
+ // Map a validated targets.win.msix config block onto buildMsix() options.
2
+ import path from "node:path";
3
+ import { assertValidMsixTarget, updaterKind } from "./msix-config.mjs";
4
+
5
+ export function msixBuildOptions({ root, target, app, sign = true, dryRun = false, receipts = {} }) {
6
+ assertValidMsixTarget(target, app);
7
+ const m = target.msix;
8
+ const at = (p) => path.resolve(root, p);
9
+ const manifest = {
10
+ ...m.identity,
11
+ capabilities: m.capabilities,
12
+ dependencies: m.runtime?.dependencies,
13
+ minWindowsVersion: m.minWindowsVersion,
14
+ maxWindowsVersionTested: m.maxWindowsVersionTested,
15
+ virtualization: m.virtualization,
16
+ };
17
+ return {
18
+ stageDir: at(m.stageDir),
19
+ out: at(m.out),
20
+ manifest,
21
+ assets: Object.fromEntries(Object.entries(m.assets).map(([k, v]) => [k, at(v)])),
22
+ runtime: m.runtime,
23
+ appInstaller: updaterKind(target) === "appinstaller" ? { ...m.appInstaller, out: at(m.appInstaller.out) } : undefined,
24
+ sign, dryRun,
25
+ peReceipt: receipts.pe, msixReceipt: receipts.msix,
26
+ receiptDir: receipts.dir,
27
+ };
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.138",
3
+ "version": "0.2.139",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
package/preflight.mjs CHANGED
@@ -23,6 +23,7 @@ import path from "node:path";
23
23
  import { spawnSync } from "./background-process.mjs";
24
24
  import { brokerManagedHost } from "./cargo-contract.mjs";
25
25
  import { createOwnershipEvidence } from "./ownership-evidence.mjs";
26
+ import { isSwiftProject } from "./project-type.mjs";
26
27
 
27
28
  export const PREFLIGHT_OK = "ok";
28
29
  export const PREFLIGHT_WARN = "warn";
@@ -179,11 +180,24 @@ export function collectPreflight({
179
180
  isBrokerManagedHost = brokerManagedHost,
180
181
  ownership = true,
181
182
  ownershipEvidenceFactory = createOwnershipEvidence,
183
+ projectType,
184
+ msix = false,
185
+ findSdkTool = findWindowsSdkTool,
182
186
  } = {}) {
183
187
  const checks = [];
184
188
  const windows = platform === "win" || platform === "win32";
189
+ const swift = isSwiftProject(projectType);
185
190
 
186
- if (appRoot) {
191
+ if (swift) {
192
+ const swiftVersion = versionProbe("swift");
193
+ checks.push(
194
+ swiftVersion
195
+ ? ok("swift", swiftVersion)
196
+ : fail("swift", "swift was not found on PATH; SwiftPM apps build with the Swift toolchain", "install the pinned Swift toolchain (swift.org / Swift on Windows) and verify: swift --version"),
197
+ );
198
+ }
199
+
200
+ if (appRoot && !swift) {
187
201
  const target = targetLink ?? nativeLayout?.targetLink ?? path.join(appRoot, "src-tauri", "target");
188
202
  const targetLabel = nativeLayout?.targetPrefix || "src-tauri/target/";
189
203
  let entry = null;
@@ -225,29 +239,27 @@ export function collectPreflight({
225
239
  }
226
240
 
227
241
  if (windows) {
228
- const makeappx = findWindowsSdkTool("makeappx.exe");
229
- checks.push(
230
- makeappx
231
- ? ok("windows-sdk", `makeappx.exe from SDK ${makeappx.version}`)
232
- : fail(
233
- "windows-sdk",
234
- "no makeappx.exe found under any installed Windows 10/11 SDK",
235
- "install the Windows SDK, or set MAKEAPPX_PATH to an explicit makeappx.exe",
236
- ),
237
- );
242
+ // makeappx/signtool are MSIX requirements; NSIS/portable targets sign through sign-windows.
243
+ if (msix) {
244
+ const makeappx = process.env.MAKEAPPX_PATH ? { version: "MAKEAPPX_PATH" } : findSdkTool("makeappx.exe");
245
+ checks.push(
246
+ makeappx
247
+ ? ok("windows-sdk", `makeappx.exe from SDK ${makeappx.version}`)
248
+ : fail(
249
+ "windows-sdk",
250
+ "msix target configured but no makeappx.exe found under any installed Windows 10/11 SDK",
251
+ "install the Windows SDK, or set MAKEAPPX_PATH to an explicit makeappx.exe",
252
+ ),
253
+ );
254
+ }
238
255
 
239
- const signtool = findWindowsSdkTool("signtool.exe");
240
- checks.push(
241
- signtool
242
- ? ok("signtool", `signtool.exe from SDK ${signtool.version}`)
243
- : warn(
244
- "signtool",
245
- "no signtool.exe found under any installed Windows SDK",
246
- "install the Windows SDK Build Tools, or set AZURE_SIGNTOOL_PATH",
247
- ),
248
- );
256
+ const signtool = findSdkTool("signtool.exe");
257
+ const signtoolMissing = "install the Windows SDK Build Tools, or set AZURE_SIGNTOOL_PATH";
258
+ if (signtool) checks.push(ok("signtool", `signtool.exe from SDK ${signtool.version}`));
259
+ else if (msix) checks.push(fail("signtool", "msix target configured but no signtool.exe found under any installed Windows SDK", signtoolMissing));
260
+ else checks.push(warn("signtool", "no signtool.exe found under any installed Windows SDK", signtoolMissing));
249
261
 
250
- const vendorsOpenssl = cargoLockPaths.some((lockPath) => graphVendorsOpenssl(lockPath));
262
+ const vendorsOpenssl = !swift && cargoLockPaths.some((lockPath) => graphVendorsOpenssl(lockPath));
251
263
  if (vendorsOpenssl) {
252
264
  const perl = resolveWindowsPerl({ env });
253
265
  checks.push(
@@ -262,7 +274,9 @@ export function collectPreflight({
262
274
  }
263
275
  }
264
276
 
265
- if (isBrokerManagedHost(env)) {
277
+ if (swift) {
278
+ // No Rust compiler cache applies to SwiftPM targets.
279
+ } else if (isBrokerManagedHost(env)) {
266
280
  checks.push(ok("compiler-cache", "broker-managed; compiler-cache selection and status are recorded in build receipts"));
267
281
  } else {
268
282
  const sccache = versionProbe("sccache");
@@ -0,0 +1,43 @@
1
+ // Project-type awareness for release preflight: which toolchain a target needs.
2
+ // Pure; filesystem access is injectable. Swift (SwiftPM) apps need `swift`, not
3
+ // cargo/rustc; Tauri/Rust targets keep their existing requirements.
4
+
5
+ import { existsSync } from "node:fs";
6
+ import path from "node:path";
7
+
8
+ export const PROJECT_TYPES = Object.freeze(["tauri", "cargo", "swiftpm"]);
9
+
10
+ /**
11
+ * Explicit `target.projectType` / `config.projectType` wins. Otherwise a Package.swift with no Cargo
12
+ * manifest (root or src-tauri) is swiftpm; everything else is tauri/cargo (unchanged behaviour).
13
+ */
14
+ export function resolveProjectType({ appRoot, config = {}, target = {}, exists = existsSync } = {}) {
15
+ const explicit = target.projectType ?? config.projectType;
16
+ if (explicit !== undefined) {
17
+ if (!PROJECT_TYPES.includes(explicit)) throw Object.assign(new Error(`projectType must be one of ${PROJECT_TYPES.join(", ")}; got ${explicit}`), { code: "PROJECT_TYPE_INVALID" });
18
+ return explicit;
19
+ }
20
+ if (appRoot) {
21
+ const roots = [appRoot, path.resolve(appRoot, config.workdir ?? ".")];
22
+ const hasSwift = roots.some((r) => exists(path.join(r, "Package.swift")));
23
+ const hasCargo = roots.some((r) => exists(path.join(r, "Cargo.toml")) || exists(path.join(r, "src-tauri", "Cargo.toml")));
24
+ if (hasSwift && !hasCargo) return "swiftpm";
25
+ }
26
+ return "tauri";
27
+ }
28
+
29
+ export const isSwiftProject = (projectType) => projectType === "swiftpm";
30
+
31
+ /** Executables the build itself needs (the config's own preflight.executables are appended by callers). */
32
+ export function requiredBuildExecutables({ projectType, packageManager = "pnpm", usesPackageManager = true } = {}) {
33
+ if (isSwiftProject(projectType)) return ["git", "swift", ...(usesPackageManager && packageManager ? [packageManager] : [])];
34
+ return ["git", packageManager, "cargo", "rustc"];
35
+ }
36
+
37
+ /** True when the Windows target packages an MSIX (needs makeappx and signtool). */
38
+ export function targetUsesMsix(target) {
39
+ return Boolean(target?.msix) || target?.signingContract === "windows-msix-signed-v1";
40
+ }
41
+
42
+ /** SwiftPM targets build and package inside prePackage (mac DMG) or the MSIX step; no separate `package` command is required. */
43
+ export const packageCommandOptional = (target, projectType) => targetUsesMsix(target) || (projectType === "swiftpm" && Boolean(target?.prePackage?.cmd));
@@ -75,6 +75,12 @@ const artifactFile = (declared) => {
75
75
  return artifact.file;
76
76
  };
77
77
  const verifiedArtifact = (file) => sealed.artifacts.get(path.basename(file));
78
+ // MSIX updates (updater.kind appinstaller) trust the package Authenticode signature (publisher-pinned in
79
+ // the manifest), so no minisign .sig exists; the route records the sealed package digest instead.
80
+ const appInstallerKind = target?.updater?.kind === "appinstaller";
81
+ const signatureFor = (artifact) => appInstallerKind
82
+ ? `msix-authenticode-sha256:${assertArtifactUnchanged(verifiedArtifact(artifact.file)) && verifiedArtifact(artifact.file).sha256}`
83
+ : signatureText(artifactFile(artifact.signature));
78
84
  const signatureText = (file) => {
79
85
  const signature = assertArtifactUnchanged(verifiedArtifact(file)).toString("utf8").trim();
80
86
  if (!signature) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", `sealed updater signature is empty: ${file}`);
@@ -89,7 +95,7 @@ function git(args) {
89
95
  if (tier === "patch" && !installers.length) fail(`${config?.app ?? "app"} ${platform} patch has no installer.artifacts`);
90
96
  if (tier === "update" && !artifacts.length) fail(`${config?.app ?? "app"} ${platform} update has no updater.artifacts`);
91
97
 
92
- if (sealed) for (const artifact of artifacts) signatureText(artifactFile(artifact.signature));
98
+ if (sealed) for (const artifact of artifacts) signatureFor(artifact);
93
99
  let releaseToken = null;
94
100
  if (!dryRun && (tier === "update" || artifacts.length > 0)) {
95
101
  try {
@@ -114,13 +120,13 @@ if (tier === "patch") {
114
120
  const installerKey = patchInstallerKey(artifact);
115
121
  assertCurrentKey(installerKey, "installer");
116
122
  const file = artifactFile(artifact.file);
117
- const signatureFile = artifactFile(artifact.signature);
123
+ if (!appInstallerKind) artifactFile(artifact.signature);
118
124
  if (!uploadedKeys.has(installerKey)) {
119
125
  console.log(`${dryRun ? "[dry-run] " : ""}UPLOAD public/${installerKey}`);
120
126
  if (!dryRun) uploadVerifiedR2Artifact(verifiedArtifact(file), installerKey, "public", { cwd: root });
121
127
  uploadedKeys.add(installerKey);
122
128
  }
123
- const signature = dryRun ? `<signature:${artifact.signature}>` : signatureText(signatureFile);
129
+ const signature = dryRun ? `<signature:${artifact.signature ?? "msix-authenticode"}>` : signatureFor(artifact);
124
130
  const artifactKey = artifact.artifactKey || installerKey.replaceAll("/", "__");
125
131
  const metadata = dryRun ? { sha256: null, sizeBytes: null } : await fileMetadata(file);
126
132
  platforms[artifact.platform] = {
@@ -210,13 +216,13 @@ const uploadedKeys = new Set();
210
216
  for (const artifact of artifacts) {
211
217
  assertCurrentKey(artifact.key, "updater");
212
218
  const file = artifactFile(artifact.file);
213
- const signatureFile = artifactFile(artifact.signature);
219
+ if (!appInstallerKind) artifactFile(artifact.signature);
214
220
  if (!uploadedKeys.has(artifact.key)) {
215
221
  console.log(`${dryRun ? "[dry-run] " : ""}UPLOAD private/${artifact.key}`);
216
222
  if (!dryRun) uploadVerifiedR2Artifact(verifiedArtifact(file), artifact.key, "private", { cwd: root });
217
223
  uploadedKeys.add(artifact.key);
218
224
  }
219
- const signature = dryRun ? `<signature:${artifact.signature}>` : signatureText(signatureFile);
225
+ const signature = dryRun ? `<signature:${artifact.signature ?? "msix-authenticode"}>` : signatureFor(artifact);
220
226
  const artifactKey = artifact.artifactKey || artifact.key.replaceAll("/", "__");
221
227
  const metadata = dryRun ? { sha256: null, sizeBytes: null } : await fileMetadata(file);
222
228
  platforms[artifact.platform] = {
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "./background-process.mjs";
2
+ import { resolveProjectType } from "./project-type.mjs";
2
3
  import { existsSync, readFileSync, realpathSync } from "node:fs";
3
4
  import path from "node:path";
4
5
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
@@ -128,18 +129,19 @@ export function resolveConfiguredBuildInputs(config, target, label = "release co
128
129
  }
129
130
 
130
131
  export function resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target, env = process.env }) {
132
+ const swift = resolveProjectType({ appRoot, config, target }) === "swiftpm";
131
133
  const nativeLayout = resolveNativeCargoLayout({
132
134
  appRoot,
133
135
  config,
134
- requireManifest: true,
135
- requireLock: true,
136
+ requireManifest: !swift,
137
+ requireLock: !swift,
136
138
  });
137
139
  const requiredInputs = [
138
140
  configPath,
139
- path.join(appRoot, "package.json"),
140
- path.join(appRoot, "pnpm-lock.yaml"),
141
- nativeLayout.manifestPath,
142
- nativeLayout.lockPath,
141
+ ...(swift
142
+ // SwiftPM: Package.swift is required; Package.resolved and the Node manifests only when they exist.
143
+ ? [path.join(appRoot, "Package.swift"), ...["Package.resolved", "package.json", "pnpm-lock.yaml"].map((f) => path.join(appRoot, f)).filter((f) => existsSync(f))]
144
+ : [path.join(appRoot, "package.json"), path.join(appRoot, "pnpm-lock.yaml"), nativeLayout.manifestPath, nativeLayout.lockPath]),
143
145
  ...(target?.preflight?.files ?? []).map((file) => path.resolve(appRoot, expandEnv(file, env))),
144
146
  ].filter((file, index, files) => files.indexOf(file) === index);
145
147
  for (const file of requiredInputs) {
package/release.mjs CHANGED
@@ -17,6 +17,10 @@ import {
17
17
  resolveNativeFinalizerConfig,
18
18
  validateNativeFinalizationOutput,
19
19
  } from "./native-release-finalization.mjs";
20
+ import { packageCommandOptional, resolveProjectType, targetUsesMsix } from "./project-type.mjs";
21
+ import { MSIX_SIGNING_CONTRACT, assertValidMsixTarget } from "./msix-config.mjs";
22
+ import { buildMsix } from "./msix-build.mjs";
23
+ import { msixBuildOptions } from "./msix-target.mjs";
20
24
  import { collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
21
25
  import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
22
26
  import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
@@ -118,7 +122,10 @@ const workdir = path.resolve(root, config.workdir ?? ".");
118
122
  const nativeLayout = resolveNativeCargoLayout({ appRoot: root, config });
119
123
  const nativeAssembly = config.nativeAssembly;
120
124
  const nativeFinalizer = resolveNativeFinalizerConfig(nativeAssembly);
121
- await validateRightKitPackageContract(root, config.app, config.hostedWorkflows);
125
+ // A SwiftPM app without a package.json has no Node package contract to validate.
126
+ if (!(resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm" && !existsSync(path.join(root, "package.json")))) {
127
+ await validateRightKitPackageContract(root, config.app, config.hostedWorkflows);
128
+ }
122
129
  validateRightKitCargoContract(root, RIGHTKIT_CARGO_ALLOWED, config.app ?? path.basename(root));
123
130
  const target = config.targets?.[opts.platform];
124
131
  if (!target) fail(`${config.app ?? "app"} has no ${opts.platform} release target`);
@@ -131,13 +138,18 @@ if (target.signed !== true) {
131
138
  if (opts.platform === "win") {
132
139
  const isNsis = target.signingContract === WINDOWS_NSIS_SIGNING_CONTRACT;
133
140
  const isPortable = target.signingContract === WINDOWS_PORTABLE_SIGNING_CONTRACT;
134
- if (!isNsis && !isPortable) fail(`${config.app ?? "app"} win must declare signingContract: ${WINDOWS_NSIS_SIGNING_CONTRACT} or ${WINDOWS_PORTABLE_SIGNING_CONTRACT}`);
141
+ const isMsix = target.signingContract === MSIX_SIGNING_CONTRACT;
142
+ if (!isNsis && !isPortable && !isMsix) fail(`${config.app ?? "app"} win must declare signingContract: ${WINDOWS_NSIS_SIGNING_CONTRACT}, ${WINDOWS_PORTABLE_SIGNING_CONTRACT} or ${MSIX_SIGNING_CONTRACT}`);
143
+ if (isMsix) {
144
+ try { assertValidMsixTarget(target, config.app ?? "app"); } catch (error) { fail(error.message); }
145
+ } else {
135
146
  if (!target.prePackage?.cmd) fail(`${config.app ?? "app"} win must declare prePackage for the raw EXE build`);
136
147
  if (!target.sign?.prePackageFiles?.length) fail(`${config.app ?? "app"} win must declare at least one sign.prePackageFiles raw EXE`);
137
148
  if (isNsis && target.sign.prePackageFiles.length !== 1) fail(`${config.app ?? "app"} win NSIS must declare exactly one sign.prePackageFiles raw EXE`);
138
149
  if (isNsis && !target.sign?.files?.length) fail(`${config.app ?? "app"} win NSIS must declare sign.files for installer signing`);
139
150
  if (isNsis && target.sign.prePackageFiles.some((file) => target.sign.files.includes(file))) fail(`${config.app ?? "app"} win raw EXE and installer signing files must be distinct`);
140
151
  if (isPortable && target.sign?.files?.length) fail(`${config.app ?? "app"} win portable release must sign every executable through sign.prePackageFiles and omit sign.files`);
152
+ }
141
153
  }
142
154
  if (opts.platform === "mac") {
143
155
  if (target.signingContract !== MACOS_PORTABLE_SIGNING_CONTRACT) fail(`${config.app ?? "app"} mac must declare signingContract: ${MACOS_PORTABLE_SIGNING_CONTRACT}`);
@@ -150,7 +162,7 @@ if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.pl
150
162
  * Assemble preflight inputs from the app's own files. Kept here (not in
151
163
  * preflight.mjs) so the check module stays free of release.mjs's config shape.
152
164
  */
153
- function releasePreflight({ config, configPath, root, repoRoot, platform, nativeLayout: suppliedNativeLayout = nativeLayout }) {
165
+ function releasePreflight({ config, configPath, root, repoRoot, platform, nativeLayout: suppliedNativeLayout = nativeLayout, projectTarget = config.targets?.[platform] }) {
154
166
  let version;
155
167
  try {
156
168
  version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version;
@@ -173,6 +185,8 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
173
185
  cargoLockPaths,
174
186
  nativeLayout: suppliedNativeLayout,
175
187
  targetLink: suppliedNativeLayout?.targetLink,
188
+ projectType: resolveProjectType({ appRoot: root, config, target: projectTarget }),
189
+ msix: targetUsesMsix(projectTarget),
176
190
  });
177
191
  }
178
192
 
@@ -234,7 +248,7 @@ if (opts.doctor) {
234
248
  const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
235
249
  const mode = "package";
236
250
  const command = target.package;
237
- if (!command) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
251
+ if (!command && !packageCommandOptional(target, resolveProjectType({ appRoot: root, config, target }))) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
238
252
  if (opts.upload && !target.publish && !target.upload) {
239
253
  fail(`${config.app ?? "app"} has no ${opts.platform} upload config`);
240
254
  }
@@ -255,7 +269,17 @@ await runConfiguredChecks({
255
269
 
256
270
  let nativeFinalization = null;
257
271
  let bundleMarkerReceipts = [];
258
- if (opts.platform === "win") {
272
+ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT) {
273
+ // MSIX order: prePackage builds + stages the payload; buildMsix signs inner PE files (DLLs, EXEs),
274
+ // packs the exact signed bytes, signs the package through sign-windows, verifies signer == Publisher,
275
+ // writes the App Installer route and the seal record. Never a second signer.
276
+ await runCommand(target.prePackage, root);
277
+ const options = msixBuildOptions({ root, target, app: config.app, dryRun: opts.dryRun, receipts: {
278
+ pe: windowsReceiptPath(root, "raw-exe"), msix: windowsReceiptPath(root, "installer"),
279
+ dir: path.dirname(windowsReceiptPath(root, "raw-exe")) } });
280
+ const built = buildMsix(options, { log: (event) => console.log(`right-release: ${JSON.stringify(event)}`) });
281
+ console.log(`right-release: msix ${built.dryRun ? "dry-run" : "built"} ${options.out}`);
282
+ } else if (opts.platform === "win") {
259
283
  const rawFiles = target.sign.prePackageFiles.map((p) => path.resolve(root, p));
260
284
  await runCommand(target.prePackage, root);
261
285
  for (const file of rawFiles) await mustExist(file, `missing raw EXE signing artifact: ${file}`);
@@ -300,7 +324,7 @@ if (opts.platform === "win") {
300
324
  }
301
325
  }
302
326
 
303
- if (opts.platform !== "mac" || runMacPhase("package")) await runCommand(command, root);
327
+ if (command && (opts.platform !== "mac" || runMacPhase("package"))) await runCommand(command, root);
304
328
 
305
329
  for (const rel of target.artifacts ?? []) {
306
330
  await mustExist(resolveReleaseArtifactPath(root, rel), `missing release artifact: ${rel}`);
@@ -29,7 +29,7 @@
29
29
  "@rightkit/platform-ui": "0.2.0",
30
30
  "@rightkit/qa": "0.4.0",
31
31
  "@rightkit/qa-chrome": "0.1.3",
32
- "@rightkit/release": "0.2.138",
32
+ "@rightkit/release": "0.2.139",
33
33
  "@rightkit/settings-ui": "0.3.0",
34
34
  "@rightkit/shell": "0.1.0",
35
35
  "@rightkit/tauri": "0.2.0",
@@ -168,7 +168,8 @@
168
168
  "0.2.134",
169
169
  "0.2.135",
170
170
  "0.2.136",
171
- "0.2.137"
171
+ "0.2.137",
172
+ "0.2.138"
172
173
  ],
173
174
  "@rightkit/qa": [
174
175
  "0.1.0",
@@ -380,7 +381,7 @@
380
381
  "@rightkit/platform-ui": "0.2.0",
381
382
  "@rightkit/qa": "0.4.0",
382
383
  "@rightkit/qa-chrome": "0.1.3",
383
- "@rightkit/release": "0.2.138",
384
+ "@rightkit/release": "0.2.139",
384
385
  "@rightkit/settings-ui": "0.3.0",
385
386
  "@rightkit/shell": "0.1.0",
386
387
  "@rightkit/tauri": "0.2.0",
@@ -0,0 +1,43 @@
1
+ // Everything the sealed `build` path does differently for a SwiftPM target, as pure/stub-friendly
2
+ // functions so build-release.mjs only branches once. Rust-only steps (rustc/cargo probes, Cargo lock
3
+ // identity, broker cargo target, Cache V2 shared Rust cache, target bridge, CARGO/SCCACHE env) are skipped.
4
+
5
+ import path from "node:path";
6
+ import { resolveSwiftCacheIdentity } from "./cache-policy.mjs";
7
+
8
+ const RUST_ENV = ["CARGO_HOME", "CARGO_TARGET_DIR", "RUSTC_WRAPPER", "SCCACHE_DIR", "SCCACHE_BASEDIRS", "SCCACHE_CACHE_SIZE", "SCCACHE_IGNORE_SERVER_IO_ERROR", "RUSTFLAGS", "RIGHT_RELEASE_CACHE_OWNER"];
9
+
10
+ export function swiftArchitecture({ target = {}, platform, hostArch = process.arch }) {
11
+ const arch = target.msix?.identity?.architecture ?? target.architecture ?? (hostArch === "arm64" ? "arm64" : "x64");
12
+ return platform === "mac" ? (arch === "x64" ? "x86_64" : arch) : arch;
13
+ }
14
+
15
+ /** exec(cmd, args) -> stdout string. Throws when swift is missing. */
16
+ export function collectSwiftToolVersions({ packageManager, exec, nodeVersion = process.version }) {
17
+ const swift = exec("swift", ["--version"]).trim();
18
+ if (!swift) throw new Error("swift --version printed nothing");
19
+ return { node: nodeVersion, packageManager: packageManager ? `${packageManager} ${exec(packageManager, ["--version"]).trim()}` : null, swift, cargo: null, rustc: null, rustHost: null, sccache: null };
20
+ }
21
+
22
+ /**
23
+ * Plan for a swiftpm sealed build: identity, forced non-shared cache mode, no broker cargo target, no target
24
+ * bridge, a plain build/cache dir under the vault, and an environment with every Rust variable removed.
25
+ */
26
+ export function planSwiftBuild({ appRoot, vaultRoot, platform, target, toolVersions, profile = "release", hostArch }) {
27
+ const architecture = swiftArchitecture({ target, platform, hostArch });
28
+ const identity = resolveSwiftCacheIdentity({ swiftVersion: toolVersions.swift, packageResolvedPath: path.join(appRoot, "Package.resolved"), architecture, profile: target.profile ?? profile });
29
+ const buildDir = path.resolve(vaultRoot, "cache", "swift-build", platform, identity.fingerprint);
30
+ return {
31
+ identity, architecture, buildDir,
32
+ cacheMode: "legacy",
33
+ managedCargoTarget: null,
34
+ targetBridge: { run: (fn) => fn(), ensure() {} },
35
+ sanitizeEnv(env) {
36
+ const next = { ...env };
37
+ for (const name of RUST_ENV) delete next[name];
38
+ next.RIGHT_RELEASE_SWIFT_BUILD_DIR = buildDir;
39
+ return next;
40
+ },
41
+ watchDirs: [buildDir, path.join(appRoot, ".build")],
42
+ };
43
+ }