@rightkit/release 0.2.151 → 0.2.153
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/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +117 -8
- package/build-release.mjs +7 -2
- package/cli/right-release.mjs +18 -5
- package/committed-source.mjs +19 -1
- package/crate-graph.mjs +121 -0
- package/development-signing.mjs +8 -0
- package/development.mjs +46 -13
- package/devtools-policy.json +5 -0
- package/devtools-policy.mjs +119 -0
- package/direct-bootstrap.mjs +9 -3
- package/dmg-build.mjs +19 -5
- package/dmg-layout.mjs +2 -2
- package/dmg-spec.mjs +97 -0
- package/execution-policy.mjs +17 -1
- package/github-release.mjs +25 -7
- package/github-upload-gate.mjs +84 -0
- package/hardeningscan.mjs +3 -0
- package/inventory-check.mjs +4 -4
- package/mac-bundle-signing.mjs +1 -1
- package/mac-sign-core.mjs +97 -0
- package/mac-signing-matrix.mjs +186 -0
- package/msix-appinstaller.mjs +4 -1
- package/msix-config.mjs +5 -1
- package/msix-repair-guard.mjs +24 -0
- package/msix-repair-guard.ps1 +356 -0
- package/ownership-catalog-config.json +6 -7
- package/ownership-command.mjs +69 -8
- package/ownership-evidence.mjs +1 -1
- package/ownership-key-authority.mjs +1 -1
- package/ownership-key-command.mjs +15 -3
- package/ownership-key-elevation.mjs +11 -2
- package/ownership-mandatory-set.json +420 -0
- package/ownership-mandatory-set.mjs +273 -0
- package/ownership.mjs +2 -2
- package/package.json +15 -3
- package/packaged-app-identity.mjs +88 -0
- package/preflight.mjs +25 -3
- package/prune-r2.mjs +28 -3
- package/publication-gate.mjs +49 -10
- package/publish-cargo.mjs +21 -1
- package/publish-swift.mjs +78 -141
- package/publish-update.mjs +24 -2
- package/qa-build-features.mjs +216 -0
- package/qa-control-scan.mjs +435 -0
- package/registry-parity.mjs +18 -1
- package/release-invocation.mjs +2 -1
- package/release-state.mjs +9 -0
- package/release-switch-registry.json +33 -0
- package/release-switch-scan.mjs +452 -0
- package/release.mjs +320 -26
- package/renamed-away.mjs +29 -0
- package/rightkit-ownership.json +682 -709
- package/rightkit-sync.mjs +8 -4
- package/rightkit-version-policy.mjs +18 -0
- package/rightkit-versions.json +211 -127
- package/runtime-budget-classes.json +4 -0
- package/runtime-budgets.mjs +870 -0
- package/runtime-bundle-scan.mjs +61 -0
- package/sign-macos.mjs +28 -30
- package/sign-updater.mjs +5 -2
- package/swift-mac-dmg.mjs +7 -2
- package/swift-package-command.mjs +1 -1
- package/upload-release.mjs +10 -37
package/crate-graph.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Cargo manifest helpers for the release gates: package identity, workspace path-dependency
|
|
2
|
+
// closure and Cargo.lock dependency closure. Pure TOML parsing (smol-toml); nothing shells out to cargo.
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { parse } from "smol-toml";
|
|
6
|
+
|
|
7
|
+
const DEP_TABLES = ["dependencies", "build-dependencies", "dev-dependencies"];
|
|
8
|
+
|
|
9
|
+
/** Nearest ancestor (or self) directory whose Cargo.toml has a [workspace] table, or null. */
|
|
10
|
+
export function findWorkspaceRoot(dir, { exists = existsSync, readFile = readFileSync } = {}) {
|
|
11
|
+
let current = path.resolve(dir);
|
|
12
|
+
for (;;) {
|
|
13
|
+
const manifest = path.join(current, "Cargo.toml");
|
|
14
|
+
if (exists(manifest)) {
|
|
15
|
+
try { if (parse(readFile(manifest, "utf8")).workspace) return current; } catch { /* unreadable: keep climbing */ }
|
|
16
|
+
}
|
|
17
|
+
const parent = path.dirname(current);
|
|
18
|
+
if (parent === current) return null;
|
|
19
|
+
current = parent;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `{ name, version, manifest }` from a crate's Cargo.toml, parsed (not pattern-matched).
|
|
25
|
+
* `version.workspace = true` resolves through the workspace root's `[workspace.package].version`.
|
|
26
|
+
* `version` is null when it cannot be resolved to a string.
|
|
27
|
+
*/
|
|
28
|
+
export function readCargoPackage(crateDir, deps = {}) {
|
|
29
|
+
const { exists = existsSync, readFile = readFileSync } = deps;
|
|
30
|
+
const manifest = parse(readFile(path.join(crateDir, "Cargo.toml"), "utf8"));
|
|
31
|
+
const pkg = manifest.package ?? {};
|
|
32
|
+
let version = pkg.version;
|
|
33
|
+
if (version && typeof version === "object" && version.workspace === true) {
|
|
34
|
+
const root = findWorkspaceRoot(crateDir, { exists, readFile });
|
|
35
|
+
version = root ? parse(readFile(path.join(root, "Cargo.toml"), "utf8")).workspace?.package?.version : undefined;
|
|
36
|
+
}
|
|
37
|
+
return { name: pkg.name ?? null, version: typeof version === "string" && version.trim() ? version : null, manifest };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function depTables(manifest, includeDev) {
|
|
41
|
+
const tables = [];
|
|
42
|
+
const take = (holder) => {
|
|
43
|
+
for (const key of DEP_TABLES) {
|
|
44
|
+
if (key === "dev-dependencies" && !includeDev) continue;
|
|
45
|
+
if (holder?.[key]) tables.push(holder[key]);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
take(manifest);
|
|
49
|
+
for (const target of Object.values(manifest.target ?? {})) take(target);
|
|
50
|
+
return tables;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Absolute directories of the workspace crates `crateDir` depends on through `path` dependencies,
|
|
55
|
+
* transitively. Dev-dependencies count for the crate itself (benches and tests use them) and not for
|
|
56
|
+
* its dependencies. `{ workspace = true }` entries resolve through `[workspace.dependencies]`.
|
|
57
|
+
*/
|
|
58
|
+
export function pathDependencyDirs(crateDir, deps = {}) {
|
|
59
|
+
const { exists = existsSync, readFile = readFileSync } = deps;
|
|
60
|
+
const root = findWorkspaceRoot(crateDir, { exists, readFile });
|
|
61
|
+
const workspaceDeps = root ? (parse(readFile(path.join(root, "Cargo.toml"), "utf8")).workspace?.dependencies ?? {}) : {};
|
|
62
|
+
const found = new Set();
|
|
63
|
+
const visit = (dir, includeDev) => {
|
|
64
|
+
const manifestPath = path.join(dir, "Cargo.toml");
|
|
65
|
+
if (!exists(manifestPath)) return;
|
|
66
|
+
const manifest = parse(readFile(manifestPath, "utf8"));
|
|
67
|
+
for (const table of depTables(manifest, includeDev)) {
|
|
68
|
+
for (const spec of Object.values(table)) {
|
|
69
|
+
if (!spec || typeof spec !== "object" || spec.path === undefined) continue;
|
|
70
|
+
const target = path.resolve(dir, spec.path);
|
|
71
|
+
if (found.has(target) || target === path.resolve(crateDir)) continue;
|
|
72
|
+
found.add(target);
|
|
73
|
+
visit(target, false);
|
|
74
|
+
}
|
|
75
|
+
// Workspace-inherited path dependencies, keyed by dependency name.
|
|
76
|
+
for (const [name, spec] of Object.entries(table)) {
|
|
77
|
+
if (spec && typeof spec === "object" && spec.workspace === true && workspaceDeps[name]?.path !== undefined) {
|
|
78
|
+
const target = path.resolve(root, workspaceDeps[name].path);
|
|
79
|
+
if (found.has(target) || target === path.resolve(crateDir)) continue;
|
|
80
|
+
found.add(target);
|
|
81
|
+
visit(target, false);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
visit(path.resolve(crateDir), true);
|
|
87
|
+
return [...found].sort();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const depName = (entry) => String(entry).split(" ")[0];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Canonical [[package]] entries of Cargo.lock reachable from `name`: the crate, every workspace crate
|
|
94
|
+
* and every external crate in its dependency graph, with version, source, checksum and edges. Two
|
|
95
|
+
* lock files give equal closures exactly when nothing in that graph changed.
|
|
96
|
+
*/
|
|
97
|
+
export function lockClosure(lockText, name) {
|
|
98
|
+
const packages = parse(lockText).package ?? [];
|
|
99
|
+
const byName = new Map();
|
|
100
|
+
for (const entry of packages) {
|
|
101
|
+
if (!byName.has(entry.name)) byName.set(entry.name, []);
|
|
102
|
+
byName.get(entry.name).push(entry);
|
|
103
|
+
}
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const out = [];
|
|
106
|
+
const queue = [...(byName.get(name) ?? [])];
|
|
107
|
+
while (queue.length) {
|
|
108
|
+
const entry = queue.pop();
|
|
109
|
+
const key = `${entry.name}@${entry.version}@${entry.source ?? ""}`;
|
|
110
|
+
if (seen.has(key)) continue;
|
|
111
|
+
seen.add(key);
|
|
112
|
+
out.push({ name: entry.name, version: entry.version, source: entry.source ?? null, checksum: entry.checksum ?? null, dependencies: [...(entry.dependencies ?? [])].sort() });
|
|
113
|
+
for (const dependency of entry.dependencies ?? []) {
|
|
114
|
+
const parts = String(dependency).split(" ");
|
|
115
|
+
const candidates = byName.get(depName(dependency)) ?? [];
|
|
116
|
+
const matching = parts[1] ? candidates.filter((c) => c.version === parts[1]) : candidates;
|
|
117
|
+
queue.push(...(matching.length ? matching : candidates));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out.sort((a, b) => `${a.name}@${a.version}@${a.source}`.localeCompare(`${b.name}@${b.version}@${b.source}`));
|
|
121
|
+
}
|
package/development-signing.mjs
CHANGED
|
@@ -38,6 +38,14 @@ function readPlist(file, context) {
|
|
|
38
38
|
catch { throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", `Invalid plist JSON: ${file}`); }
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** CFBundleExecutable of a bundle, read through the same plutil path (and injectable runner) as every other plist here. */
|
|
42
|
+
export function readBundleExecutable(bundle, { root, env = process.env, run = spawnSync }) {
|
|
43
|
+
const file = path.join(bundle, "Contents", "Info.plist");
|
|
44
|
+
const name = readPlist(file, { root, env, run }).CFBundleExecutable;
|
|
45
|
+
if (typeof name !== "string" || !name.trim() || /[\\/]/.test(name)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", `Info.plist has no usable CFBundleExecutable: ${file}`);
|
|
46
|
+
return name.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
41
49
|
function decodeProfile(file, context) {
|
|
42
50
|
const cms = checked("security", ["cms", "-D", "-i", file], context, "DEV_MAC_SIGNING_PROFILE_INVALID");
|
|
43
51
|
// plutil JSON conversion rejects the date/data values in provisioning profiles.
|
package/development.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
|
|
|
9
9
|
import { acquireManagedRunLease, managedRuntimeClient, managedRuntimeCommand, runCargoSync } from "./managed-cargo.mjs";
|
|
10
10
|
import { BUNDLE_MARKERS, bundleMarkerBuffer, readTauriBundleMarkers } from "./tauri-bundle-marker.mjs";
|
|
11
11
|
import { installPortableDevelopment } from "./portable-development.mjs";
|
|
12
|
-
import { signMacosDevelopmentBundle } from "./development-signing.mjs";
|
|
12
|
+
import { readBundleExecutable, signMacosDevelopmentBundle } from "./development-signing.mjs";
|
|
13
13
|
import { macosCodesignEnvironment } from "./codesign-environment.mjs";
|
|
14
14
|
|
|
15
15
|
export const DEVELOPMENT_PROFILE = "release-iterate";
|
|
@@ -113,23 +113,56 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null
|
|
|
113
113
|
rmSync(backup, { recursive: true, force: true });
|
|
114
114
|
cpSync(app, staging, { recursive: true, verbatimSymlinks: true });
|
|
115
115
|
let signing;
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
let executableName;
|
|
117
|
+
try {
|
|
118
|
+
signing = signMacosDevelopmentBundle({ root, bundle: staging, descriptor, config, env, run });
|
|
119
|
+
executableName = readBundleExecutable(staging, { root, env, run });
|
|
120
|
+
} catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
|
|
121
|
+
swapInstalledMacosBundle({ staging, destination, backup, executableName });
|
|
122
|
+
if (signing.provisioningProfile) signing.provisioningProfile = path.join(destination, "Contents", "embedded.provisionprofile");
|
|
123
|
+
console.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; profile=${signing.provisioningProfile ?? "none"}`);
|
|
124
|
+
return signing;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class DevelopmentInstallError extends Error {
|
|
128
|
+
constructor(code, message) { super(message); this.name = "DevelopmentInstallError"; this.code = code; }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const NODE_FS = { existsSync, readFileSync, renameSync, rmSync, statSync };
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Move the signed staging bundle into place and verify it before the previous app's backup is removed: Info.plist exists and
|
|
135
|
+
* the installed main executable (`executableName`, the staged CFBundleExecutable read through plutil by the caller) exists with the same SHA-256 as the staged one. Any failure removes the
|
|
136
|
+
* new install, restores the backup and throws a DevelopmentInstallError (code DEV_INSTALL_VERIFICATION_FAILED,
|
|
137
|
+
* DEV_INSTALL_EXECUTABLE_MISSING or DEV_INSTALL_EXECUTABLE_MISMATCH). `fs` is the file-system seam (tests inject it).
|
|
138
|
+
*/
|
|
139
|
+
export function swapInstalledMacosBundle({ staging, destination, backup, executableName, fs = NODE_FS }) {
|
|
140
|
+
if (typeof executableName !== "string" || !executableName) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", "swapInstalledMacosBundle requires the staged CFBundleExecutable name");
|
|
141
|
+
const executable = path.join("Contents", "MacOS", executableName);
|
|
142
|
+
const digest = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
143
|
+
let expected;
|
|
144
|
+
try { expected = digest(path.join(staging, executable)); }
|
|
145
|
+
catch (error) {
|
|
146
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
147
|
+
throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `staged app has no main executable ${executable}: ${staging} (${error.message})`);
|
|
148
|
+
}
|
|
118
149
|
let backedUp = false;
|
|
119
150
|
try {
|
|
120
|
-
if (existsSync(destination)) { renameSync(destination, backup); backedUp = true; }
|
|
121
|
-
renameSync(staging, destination);
|
|
122
|
-
if (!existsSync(path.join(destination, "Contents", "Info.plist"))) throw new
|
|
123
|
-
|
|
151
|
+
if (fs.existsSync(destination)) { fs.renameSync(destination, backup); backedUp = true; }
|
|
152
|
+
fs.renameSync(staging, destination);
|
|
153
|
+
if (!fs.existsSync(path.join(destination, "Contents", "Info.plist"))) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", `installed app verification failed: ${destination}`);
|
|
154
|
+
const installed = path.join(destination, executable);
|
|
155
|
+
let actual;
|
|
156
|
+
try { actual = digest(installed); }
|
|
157
|
+
catch { throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `installed app verification failed: main executable ${executable} is missing in ${destination}`); }
|
|
158
|
+
if (actual !== expected) throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISMATCH", `installed app verification failed: ${executable} SHA-256 ${actual} does not match the staged ${expected}`);
|
|
159
|
+
if (backedUp) fs.rmSync(backup, { recursive: true, force: true });
|
|
124
160
|
} catch (error) {
|
|
125
|
-
rmSync(destination, { recursive: true, force: true });
|
|
126
|
-
if (backedUp && existsSync(backup)) renameSync(backup, destination);
|
|
127
|
-
rmSync(staging, { recursive: true, force: true });
|
|
161
|
+
fs.rmSync(destination, { recursive: true, force: true });
|
|
162
|
+
if (backedUp && fs.existsSync(backup)) fs.renameSync(backup, destination);
|
|
163
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
128
164
|
throw error;
|
|
129
165
|
}
|
|
130
|
-
if (signing.provisioningProfile) signing.provisioningProfile = path.join(destination, "Contents", "embedded.provisionprofile");
|
|
131
|
-
console.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; profile=${signing.provisioningProfile ?? "none"}`);
|
|
132
|
-
return signing;
|
|
133
166
|
}
|
|
134
167
|
|
|
135
168
|
export function resolveTauriArtifact(descriptor, { root, platform, env = process.env, runCargo = runCargoSync }) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"note": "Owner's fixed design: every Right app has no access to DevTools; ViewRight alone may inspect the HTML content it opens, never its own UI or shell. Only an app listed here may declare devtools: \"content-only\" in its right-release config. Edit only on the owner's instruction.",
|
|
4
|
+
"contentOnlyApps": ["viewright"]
|
|
5
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Release-side half of the DevTools policy (the runtime half is crates/rightkit-tauri-shell/src/devtools.rs).
|
|
2
|
+
//
|
|
3
|
+
// Owner's fixed design: every Right app has absolutely NO access to DevTools. The single exception is ViewRight,
|
|
4
|
+
// which opens HTML documents and may inspect that opened content only, never its own UI or shell.
|
|
5
|
+
//
|
|
6
|
+
// Rules, applied to every release and update build before anything is built:
|
|
7
|
+
// - `devtools` is refused for every app (qa-build-features.mjs, qa-control-scan.mjs).
|
|
8
|
+
// - Exception: an app whose right-release config declares `devtools: "content-only"` AND is listed in
|
|
9
|
+
// devtools-policy.json (`contentOnlyApps`, today only `viewright`) AND whose Cargo manifest depends on
|
|
10
|
+
// rightkit-tauri-shell with the `content-devtools` feature (the only way to select DevToolsPolicy::ContentOnly)
|
|
11
|
+
// AND whose build.rs declares an application ACL manifest (without one Tauri lets local-origin content call
|
|
12
|
+
// every app command, so a content webview would not be IPC-isolated).
|
|
13
|
+
// - A manifest that turns on `content-devtools` without the declaration is refused as well.
|
|
14
|
+
// - The allowance is recorded in the release evidence (the sealed manifest, field `devtools`).
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { parseCargoFeaturesAndDependencies } from "./qa-build-features.mjs";
|
|
19
|
+
|
|
20
|
+
// Keep identical to `CONTENT_ONLY_MARKER` in crates/rightkit-tauri-shell/src/devtools.rs and qa-control-scan.mjs.
|
|
21
|
+
export const CONTENT_ONLY_MARKER = "RIGHTKIT-SHELL-DEVTOOLS-CONTENT-ONLY-COMPILED";
|
|
22
|
+
export const DEVTOOLS_POLICY_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), "devtools-policy.json");
|
|
23
|
+
const SHELL_CRATE = "rightkit-tauri-shell";
|
|
24
|
+
// The crate was renamed from rightkit-shell; apps still on the old published name stay covered until they migrate.
|
|
25
|
+
const SHELL_CRATES = [SHELL_CRATE, "rightkit-shell"];
|
|
26
|
+
const SHELL_FEATURE = "content-devtools";
|
|
27
|
+
|
|
28
|
+
export function readContentOnlyApps(file = DEVTOOLS_POLICY_FILE) {
|
|
29
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
30
|
+
if (parsed.schema !== 1 || !Array.isArray(parsed.contentOnlyApps) || parsed.contentOnlyApps.some((a) => typeof a !== "string")) {
|
|
31
|
+
throw new Error(`${file}: expected { schema: 1, contentOnlyApps: [string] }`);
|
|
32
|
+
}
|
|
33
|
+
return Object.freeze([...parsed.contentOnlyApps]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// "denied" | "content-only"; anything else is a configuration error.
|
|
37
|
+
export function declaredDevtools(config) {
|
|
38
|
+
const value = config?.devtools;
|
|
39
|
+
if (value === undefined || value === null || value === "denied") return "denied";
|
|
40
|
+
if (value === "content-only") return "content-only";
|
|
41
|
+
throw new Error(`right-release config: devtools must be "denied" or "content-only", got ${JSON.stringify(value)}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Does the manifest turn on rightkit-tauri-shell's content-devtools (directly or through an app feature it lists)?
|
|
45
|
+
export function selectedContentDevtoolsCrate(manifestText) {
|
|
46
|
+
const { features, dependencies } = parseCargoFeaturesAndDependencies(manifestText);
|
|
47
|
+
for (const crate of SHELL_CRATES) {
|
|
48
|
+
const shell = dependencies.get(crate);
|
|
49
|
+
if (!shell) continue;
|
|
50
|
+
if (shell.features.includes(SHELL_FEATURE)) return crate;
|
|
51
|
+
// An app feature such as `content-devtools = ["rightkit-tauri-shell/content-devtools"]` or a `devtools` alias.
|
|
52
|
+
for (const items of features.values()) {
|
|
53
|
+
if (items.some((item) => item === `${crate}/${SHELL_FEATURE}` || item === `${crate}?/${SHELL_FEATURE}`)) return crate;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
export function manifestSelectsContentDevtools(manifestText) {
|
|
59
|
+
return selectedContentDevtoolsCrate(manifestText) !== null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function appManifestDeclared(buildRsText) {
|
|
63
|
+
return /\bAppManifest\b/.test(buildRsText) && /\.commands\s*\(/.test(buildRsText);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readIfExists(file) {
|
|
67
|
+
return existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Returns { mode: "denied" } or { mode: "content-only", app, allowListed, shellFeature, appAclManifest, evidence }.
|
|
71
|
+
// Throws (a refusal) when the declaration and the allow-list or the sources disagree. `contentOnlyApps` is injectable
|
|
72
|
+
// so a test can prove that any other app declaring the allowance is refused.
|
|
73
|
+
export function resolveDevtoolsAllowance({ config, root, contentOnlyApps = readContentOnlyApps() }) {
|
|
74
|
+
const declared = declaredDevtools(config);
|
|
75
|
+
const app = config?.app;
|
|
76
|
+
const manifests = ["src-tauri/Cargo.toml", "Cargo.toml"].map((rel) => ({ rel, text: readIfExists(path.join(root, rel)) })).filter((m) => m.text !== null);
|
|
77
|
+
const selecting = manifests.filter((m) => manifestSelectsContentDevtools(m.text));
|
|
78
|
+
if (declared === "denied") {
|
|
79
|
+
if (selecting.length) {
|
|
80
|
+
throw new Error(`${app ?? "app"}: ${selecting.map((m) => m.rel).join(", ")} enables rightkit-tauri-shell \`${SHELL_FEATURE}\` but the right-release config does not declare devtools: "content-only"; no app ships DevTools without that declaration`);
|
|
81
|
+
}
|
|
82
|
+
return { mode: "denied" };
|
|
83
|
+
}
|
|
84
|
+
const problems = [];
|
|
85
|
+
if (typeof app !== "string" || !contentOnlyApps.includes(app)) {
|
|
86
|
+
problems.push(`app \`${app ?? "<unnamed>"}\` is not on the content-only DevTools allow-list (${contentOnlyApps.join(", ") || "empty"}); only ViewRight may inspect opened content, and no Right app may inspect its own UI or shell`);
|
|
87
|
+
}
|
|
88
|
+
if (selecting.length === 0) {
|
|
89
|
+
problems.push(`the Cargo manifest does not depend on ${SHELL_CRATE} with the \`${SHELL_FEATURE}\` feature, so rightkit-tauri-shell's DevToolsPolicy::ContentOnly cannot be selected`);
|
|
90
|
+
}
|
|
91
|
+
const shellCrate = selecting.length ? selectedContentDevtoolsCrate(selecting[0].text) : SHELL_CRATE;
|
|
92
|
+
const buildRs = readIfExists(path.join(root, "src-tauri", "build.rs")) ?? readIfExists(path.join(root, "build.rs"));
|
|
93
|
+
if (buildRs === null || !appManifestDeclared(buildRs)) {
|
|
94
|
+
problems.push("build.rs declares no application ACL manifest (tauri_build AppManifest with commands(..)); without one local content can call every app command");
|
|
95
|
+
}
|
|
96
|
+
if (problems.length) throw new Error(`devtools: "content-only" refused:\n- ${problems.join("\n- ")}`);
|
|
97
|
+
return {
|
|
98
|
+
mode: "content-only",
|
|
99
|
+
app,
|
|
100
|
+
allowListed: true,
|
|
101
|
+
shellFeature: `${shellCrate}/${SHELL_FEATURE}`,
|
|
102
|
+
appAclManifest: true,
|
|
103
|
+
marker: CONTENT_ONLY_MARKER,
|
|
104
|
+
evidence: `${app} is allow-listed for content-only DevTools (devtools-policy.json); ${selecting[0].rel} selects ${shellCrate}/${SHELL_FEATURE}; build.rs declares an application ACL manifest`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// What the sealed manifest records.
|
|
109
|
+
export function devtoolsEvidence(allowance) {
|
|
110
|
+
return allowance?.mode === "content-only"
|
|
111
|
+
? { mode: "content-only", app: allowance.app, shellFeature: allowance.shellFeature, appAclManifest: true, marker: allowance.marker, evidence: allowance.evidence }
|
|
112
|
+
: { mode: "denied" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Re-checked at seal verification and upload: the manifest alone is not trusted, the allow-list is read again.
|
|
116
|
+
export function sealedManifestAllowsContentDevtools(manifest, contentOnlyApps = readContentOnlyApps()) {
|
|
117
|
+
const devtools = manifest?.devtools;
|
|
118
|
+
return devtools?.mode === "content-only" && typeof manifest.app === "string" && devtools.app === manifest.app && contentOnlyApps.includes(manifest.app);
|
|
119
|
+
}
|
package/direct-bootstrap.mjs
CHANGED
|
@@ -449,22 +449,28 @@ function Invoke-BoundedExe($Path, [string[]]$Arguments, [int]$TimeoutSeconds) {
|
|
|
449
449
|
" $StableBin = Split-Path $StableExe -Parent; $InstallRootResolved = [IO.Path]::GetFullPath($InstallRoot).TrimEnd([IO.Path]::DirectorySeparatorChar); $UserPath = [Environment]::GetEnvironmentVariable('Path','User'); $Kept = @($UserPath -split ';' | Where-Object { $Entry = $_.Trim(); $Entry -ne '' -and -not ($Entry.TrimEnd([IO.Path]::DirectorySeparatorChar).StartsWith(($InstallRootResolved + [IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase) -and -not $Entry.TrimEnd([IO.Path]::DirectorySeparatorChar).Equals($StableBin.TrimEnd([IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase)) }); $NewPath = ((@($StableBin) + $Kept) -join ';'); if ($NewPath -ne $UserPath) { [Environment]::SetEnvironmentVariable('Path', $NewPath, 'User') }",
|
|
450
450
|
// Journal cleanup must succeed before discarding the prior pointer. If
|
|
451
451
|
// cleanup fails, catch still has a rollback anchor to restore.
|
|
452
|
+
// Version directories are compared by NAME, never by full path, here and in the rollback below.
|
|
453
|
+
// A junction's stored target and the paths this script builds can spell one directory two ways
|
|
454
|
+
// (a Windows short name such as RUNNER~1, another junction on the way, a different root form),
|
|
455
|
+
// and a full-path comparison that misses would prune the prior version, skip the rollback
|
|
456
|
+
// activation, or remove the version the stable pointer is still on. Every version lives
|
|
457
|
+
// directly under the versions root, so its name identifies it.
|
|
452
458
|
// Retention: after a healthy activation keep only the new version and the prior target (the
|
|
453
459
|
// app's first-start probation may still roll back to it, then deletes it). Every other version
|
|
454
460
|
// is removed; one a running process still holds fails the rename and is retried next install.
|
|
455
|
-
" $KeepVersions = @([IO.Path]::
|
|
461
|
+
" $KeepVersions = @([IO.Path]::GetFileName(([string]$VersionRoot).TrimEnd([IO.Path]::DirectorySeparatorChar))); if ($PriorTarget) { $KeepVersions += [IO.Path]::GetFileName(([string]$PriorTarget).TrimEnd([IO.Path]::DirectorySeparatorChar)) }; Get-ChildItem -LiteralPath $VersionsRoot -Directory -Force -ErrorAction SilentlyContinue | Where-Object { $Candidate = $_.Name; -not ($KeepVersions | Where-Object { $_.Equals($Candidate, [StringComparison]::OrdinalIgnoreCase) }) } | ForEach-Object { try { $Doomed = Join-Path $VersionsRoot ('.prune-' + [guid]::NewGuid()); Rename-Item -LiteralPath $_.FullName -NewName (Split-Path -Leaf $Doomed) -ErrorAction Stop; Remove-Item -LiteralPath $Doomed -Recurse -Force -ErrorAction SilentlyContinue } catch { } }",
|
|
456
462
|
" Remove-Item -Force $Journal; Remove-StableJunction $PreviousLink; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
|
|
457
463
|
"} catch {",
|
|
458
464
|
" $InstallError = $_; $RollbackErrors = New-Object 'System.Collections.Generic.List[string]'",
|
|
459
465
|
" try { if ($Switched -or $PriorDetached) { Remove-StableJunction $Current }; if ($PriorDetached -and (Test-Path $PreviousLink)) { Rename-Item -LiteralPath $PreviousLink -NewName (Split-Path -Leaf $Current) } } catch { $RollbackErrors.Add($_.Exception.Message) }",
|
|
460
|
-
" try { if ($PriorTarget -and (Test-Path $Current) -and $PriorCliCapable -and ([string](Get-Item -LiteralPath $Current).Target -eq [string]$PriorTarget)) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate ([string]$PriorTarget) $PriorVersion); $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); Invoke-BoundedExe $PriorExe $PriorActivationArgs 240 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Rollback activation failed' }; $PriorHealthText = ((Invoke-BoundedExe $PriorExe $PriorStatusArgs 120) | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Rollback health failed' }; $PriorHealth = $PriorHealthText | ConvertFrom-Json; try { Assert-Health $PriorHealth $PriorVersion } catch { throw ('Prior health was not restored: ' + $_.Exception.Message) } } } catch { $RollbackErrors.Add($_.Exception.Message) }",
|
|
466
|
+
" try { if ($PriorTarget -and (Test-Path $Current) -and $PriorCliCapable -and ([IO.Path]::GetFileName(([string](Get-Item -LiteralPath $Current).Target).TrimEnd([IO.Path]::DirectorySeparatorChar)) -eq [IO.Path]::GetFileName(([string]$PriorTarget).TrimEnd([IO.Path]::DirectorySeparatorChar)))) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate ([string]$PriorTarget) $PriorVersion); $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); Invoke-BoundedExe $PriorExe $PriorActivationArgs 240 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Rollback activation failed' }; $PriorHealthText = ((Invoke-BoundedExe $PriorExe $PriorStatusArgs 120) | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Rollback health failed' }; $PriorHealth = $PriorHealthText | ConvertFrom-Json; try { Assert-Health $PriorHealth $PriorVersion } catch { throw ('Prior health was not restored: ' + $_.Exception.Message) } } } catch { $RollbackErrors.Add($_.Exception.Message) }",
|
|
461
467
|
String.raw` try {
|
|
462
468
|
$CandidatePath = [IO.Path]::GetFullPath($VersionRoot).TrimEnd([IO.Path]::DirectorySeparatorChar)
|
|
463
469
|
$VersionsPrefix = [IO.Path]::GetFullPath($VersionsRoot).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
|
|
464
470
|
$ActiveTarget = if (Test-Path -LiteralPath $Current) { [string](Get-Item -LiteralPath $Current).Target } else { $null }
|
|
465
471
|
if ($ActiveTarget -and -not [IO.Path]::IsPathRooted($ActiveTarget)) { $ActiveTarget = Join-Path (Split-Path -Parent $Current) $ActiveTarget }
|
|
466
472
|
if (-not $CandidatePath.StartsWith($VersionsPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Failed version cleanup escapes versions root' }
|
|
467
|
-
if ($ActiveTarget -and [IO.Path]::GetFullPath($ActiveTarget).TrimEnd([IO.Path]::DirectorySeparatorChar).Equals($CandidatePath, [StringComparison]::OrdinalIgnoreCase)) { throw 'Failed version is still active; preserved for recovery' }
|
|
473
|
+
if ($ActiveTarget -and [IO.Path]::GetFileName([IO.Path]::GetFullPath($ActiveTarget).TrimEnd([IO.Path]::DirectorySeparatorChar)).Equals([IO.Path]::GetFileName($CandidatePath), [StringComparison]::OrdinalIgnoreCase)) { throw 'Failed version is still active; preserved for recovery' }
|
|
468
474
|
if (Test-Path -LiteralPath $CandidatePath) { Remove-Item -LiteralPath $CandidatePath -Recurse -Force }
|
|
469
475
|
} catch { $RollbackErrors.Add($_.Exception.Message) }
|
|
470
476
|
if ($RollbackErrors.Count) {
|
package/dmg-build.mjs
CHANGED
|
@@ -3,8 +3,10 @@ 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 { readDmgSpec } from "./dmg-spec.mjs";
|
|
6
7
|
import { detachWithRetrySync } from "./hdiutil-detach.mjs";
|
|
7
8
|
import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
|
|
9
|
+
import { assertNoQaControl } from "./qa-control-scan.mjs";
|
|
8
10
|
|
|
9
11
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
10
12
|
const pair = (value, label, positive = false) => {
|
|
@@ -15,11 +17,20 @@ const pair = (value, label, positive = false) => {
|
|
|
15
17
|
return numbers;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
const definedOnly = (object) => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
|
|
21
|
+
|
|
22
|
+
/** Layer explicit options over an appdmg-style spec file (`spec`); explicit values win. */
|
|
23
|
+
export function resolveDmgOptions(input = {}) {
|
|
24
|
+
const { spec, ...explicit } = input;
|
|
25
|
+
return spec ? { ...readDmgSpec(spec, { app: explicit.app }), ...definedOnly(explicit) } : explicit;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildDmg(input = {},
|
|
20
29
|
{ platform = process.platform, run = spawnSync, makeAlias = backgroundAlias,
|
|
21
30
|
log = (event) => console.error(JSON.stringify(event)), sleep } = {}) {
|
|
22
31
|
if (platform !== "darwin") throw fail("DMG_NATIVE_HOST_REQUIRED", "DMG creation requires macOS");
|
|
32
|
+
let { app, out, volname, background, iconSize = 128, appPosition = [158, 231],
|
|
33
|
+
applicationsPosition = [492, 231], window = [660, 400], windowPosition = [100, 100], volumeIcon } = resolveDmgOptions(input);
|
|
23
34
|
if (!app || !out || !volname) throw fail("DMG_INVALID_OPTIONS", "--app, --out & --volname are required");
|
|
24
35
|
app = path.resolve(app); out = path.resolve(out);
|
|
25
36
|
if (!app.endsWith(".app") || !statSync(app).isDirectory() || !existsSync(path.join(app, "Contents", "Info.plist"))) {
|
|
@@ -30,7 +41,7 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
|
|
|
30
41
|
iconSize = Number(iconSize);
|
|
31
42
|
if (!Number.isInteger(iconSize) || iconSize < 16 || iconSize > 512) throw fail("DMG_INVALID_OPTIONS", "--icon-size must be 16..512");
|
|
32
43
|
appPosition = pair(appPosition, "--app-position"); applicationsPosition = pair(applicationsPosition, "--applications-position");
|
|
33
|
-
window = pair(window, "--window", true);
|
|
44
|
+
window = pair(window, "--window", true); windowPosition = pair(windowPosition, "--window-position");
|
|
34
45
|
if (volumeIcon) {
|
|
35
46
|
volumeIcon = path.resolve(volumeIcon);
|
|
36
47
|
if (!existsSync(volumeIcon) || !statSync(volumeIcon).isFile() || !/\.icns$/i.test(volumeIcon)) throw fail("DMG_INVALID_OPTIONS", "--volume-icon must be an .icns file");
|
|
@@ -66,6 +77,9 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
|
|
|
66
77
|
try {
|
|
67
78
|
mkdirSync(stage); mkdirSync(mount);
|
|
68
79
|
command("/usr/bin/ditto", [app, path.join(stage, path.basename(app))]);
|
|
80
|
+
// The compressed DMG cannot be scanned afterwards: scan the staged payload before it is packed. An unreadable
|
|
81
|
+
// path or a finding fails; the "no binary at all" check lives in release.mjs, which knows the signed app.
|
|
82
|
+
assertNoQaControl([stage], "dmg staging");
|
|
69
83
|
symlinkSync("/Applications", path.join(stage, "Applications"));
|
|
70
84
|
const relative = background ? path.join(".background", path.basename(background)) : null;
|
|
71
85
|
if (relative) { mkdirSync(path.join(stage, ".background")); copyFileSync(background, path.join(stage, relative)); }
|
|
@@ -84,7 +98,7 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
|
|
|
84
98
|
}
|
|
85
99
|
const alias = relative ? makeAlias({ mount, volname, relative }) : undefined;
|
|
86
100
|
writeFileSync(path.join(mount, ".DS_Store"), dmgDSStore({ appName: path.basename(app), iconSize,
|
|
87
|
-
appPosition, applicationsPosition, window, alias }));
|
|
101
|
+
appPosition, applicationsPosition, window, windowPosition, alias }));
|
|
88
102
|
detach(); attached = false;
|
|
89
103
|
command("/usr/bin/hdiutil", ["convert", rw, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", compressed]);
|
|
90
104
|
command("/usr/bin/hdiutil", ["verify", compressed]);
|
|
@@ -107,7 +121,7 @@ export function buildDmg({ app, out, volname, background, iconSize = 128, appPos
|
|
|
107
121
|
export function parseDmgArguments(args) {
|
|
108
122
|
if (args[0] !== "build") throw fail("DMG_INVALID_OPTIONS", "expected dmg build");
|
|
109
123
|
const flags = new Map([["--app", "app"], ["--out", "out"], ["--volname", "volname"], ["--background", "background"],
|
|
110
|
-
["--icon-size", "iconSize"], ["--app-position", "appPosition"], ["--applications-position", "applicationsPosition"], ["--window", "window"], ["--volume-icon", "volumeIcon"]]);
|
|
124
|
+
["--icon-size", "iconSize"], ["--app-position", "appPosition"], ["--applications-position", "applicationsPosition"], ["--window", "window"], ["--window-position", "windowPosition"], ["--volume-icon", "volumeIcon"], ["--spec", "spec"]]);
|
|
111
125
|
const options = {};
|
|
112
126
|
for (let i = 1; i < args.length; i += 2) {
|
|
113
127
|
const key = flags.get(args[i]);
|
package/dmg-layout.mjs
CHANGED
|
@@ -81,7 +81,7 @@ export function backgroundAlias({ mount, volname, relative, stat = statSync }) {
|
|
|
81
81
|
|
|
82
82
|
/** One sorted B-tree leaf in a valid Bud1 allocator, sufficient for DMG layout. */
|
|
83
83
|
export function dmgDSStore({ appName, iconSize = 128, appPosition = [158, 231],
|
|
84
|
-
applicationsPosition = [492, 231], window = [660, 400], alias } = {}) {
|
|
84
|
+
applicationsPosition = [492, 231], window = [660, 400], windowPosition = [100, 100], alias } = {}) {
|
|
85
85
|
const entries = [];
|
|
86
86
|
const add = (name, code, type, bytes) => entries.push({ name, code, bytes: Buffer.concat([
|
|
87
87
|
u32(name.length), utf16(name), Buffer.from(code + type), ...(type === "blob" ? [u32(bytes.length)] : []), bytes,
|
|
@@ -90,7 +90,7 @@ export function dmgDSStore({ appName, iconSize = 128, appPosition = [158, 231],
|
|
|
90
90
|
add(".", "icvl", "type", Buffer.from("icnv"));
|
|
91
91
|
add(".", "bwsp", "blob", finderPlist({ ShowStatusBar: false, ShowToolbar: false, ShowTabView: false,
|
|
92
92
|
ShowPathbar: false, ShowSidebar: false, ContainerShowSidebar: false, PreviewPaneVisibility: false,
|
|
93
|
-
SidebarWidth: 0, WindowBounds: `{{
|
|
93
|
+
SidebarWidth: 0, WindowBounds: `{{${windowPosition[0]}, ${windowPosition[1]}}, {${window[0]}, ${window[1]}}}` }));
|
|
94
94
|
add(".", "icvp", "blob", finderPlist({ viewOptionsVersion: 1, backgroundType: alias ? 2 : 0,
|
|
95
95
|
backgroundColorRed: 1, backgroundColorGreen: 1, backgroundColorBlue: 1,
|
|
96
96
|
gridOffsetX: 0, gridOffsetY: 0, gridSpacing: 100, arrangeBy: "none", showIconPreview: true,
|
package/dmg-spec.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// appdmg-style DMG spec (`dmg build --spec <file.json>`, `targets.mac.dmg.spec`) mapped onto the
|
|
2
|
+
// headless hdiutil pipeline in dmg-build.mjs. Only fields the pipeline can render are accepted; any
|
|
3
|
+
// other appdmg field fails with DMG_SPEC_UNSUPPORTED instead of being silently dropped.
|
|
4
|
+
//
|
|
5
|
+
// Supported: title, icon (.icns volume icon), background (PNG/TIFF), icon-size,
|
|
6
|
+
// window.size {width,height}, window.position {x,y}, format "UDZO", filesystem "HFS+",
|
|
7
|
+
// contents: one `file` entry for the .app and one `link` entry to /Applications (x,y = icon centre).
|
|
8
|
+
// Unsupported: background-color, code-sign (the release pipeline signs the DMG), license, other formats and
|
|
9
|
+
// filesystems, extra files or links, `position` entries, any unknown key.
|
|
10
|
+
// Relative `background` / `icon` paths resolve against the spec file's directory.
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
const fail = (message, code = "DMG_SPEC_INVALID") => Object.assign(new Error(message), { code });
|
|
15
|
+
const unsupported = (message) => fail(message, "DMG_SPEC_UNSUPPORTED");
|
|
16
|
+
const SPEC_KEYS = new Set(["title", "icon", "background", "icon-size", "window", "format", "filesystem", "contents"]);
|
|
17
|
+
const UNSUPPORTED_KEYS = new Map([
|
|
18
|
+
["background-color", "background-color is not rendered; use a background image"],
|
|
19
|
+
["code-sign", "code-sign is not supported: right-release Developer ID signs the DMG in its own sign step"],
|
|
20
|
+
["license", "license agreements are not supported"],
|
|
21
|
+
]);
|
|
22
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
23
|
+
const int = (value, label, { min = 0 } = {}) => {
|
|
24
|
+
if (!Number.isInteger(value) || value < min || value > 32767) throw fail(`${label} must be an integer ${min}..32767`);
|
|
25
|
+
return value;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Parse an appdmg-style spec object into buildDmg options. `app` (optional) is the .app path being packed. */
|
|
29
|
+
export function parseDmgSpec(spec, { baseDir = process.cwd(), app } = {}) {
|
|
30
|
+
if (!isObject(spec)) throw fail("DMG spec must be a JSON object");
|
|
31
|
+
for (const key of Object.keys(spec)) {
|
|
32
|
+
if (UNSUPPORTED_KEYS.has(key)) throw unsupported(`DMG spec: ${UNSUPPORTED_KEYS.get(key)}`);
|
|
33
|
+
if (!SPEC_KEYS.has(key)) throw unsupported(`DMG spec: unsupported field ${JSON.stringify(key)}`);
|
|
34
|
+
}
|
|
35
|
+
const options = {};
|
|
36
|
+
if (spec.title !== undefined) {
|
|
37
|
+
if (typeof spec.title !== "string" || !spec.title) throw fail("DMG spec: title must be a non-empty string");
|
|
38
|
+
options.volname = spec.title;
|
|
39
|
+
}
|
|
40
|
+
const file = (value, label) => {
|
|
41
|
+
if (typeof value !== "string" || !value) throw fail(`DMG spec: ${label} must be a non-empty path string`);
|
|
42
|
+
return path.resolve(baseDir, value);
|
|
43
|
+
};
|
|
44
|
+
if (spec.icon !== undefined) options.volumeIcon = file(spec.icon, "icon");
|
|
45
|
+
if (spec.background !== undefined) options.background = file(spec.background, "background");
|
|
46
|
+
if (spec["icon-size"] !== undefined) options.iconSize = int(spec["icon-size"], "DMG spec: icon-size", { min: 16 });
|
|
47
|
+
if (spec.format !== undefined && spec.format !== "UDZO") throw unsupported(`DMG spec: format ${JSON.stringify(spec.format)} is not supported (the pipeline writes UDZO)`);
|
|
48
|
+
if (spec.filesystem !== undefined && spec.filesystem !== "HFS+") throw unsupported(`DMG spec: filesystem ${JSON.stringify(spec.filesystem)} is not supported (the pipeline writes HFS+)`);
|
|
49
|
+
if (spec.window !== undefined) {
|
|
50
|
+
if (!isObject(spec.window)) throw fail("DMG spec: window must be an object");
|
|
51
|
+
for (const key of Object.keys(spec.window)) if (!["size", "position"].includes(key)) throw unsupported(`DMG spec: unsupported field window.${key}`);
|
|
52
|
+
if (spec.window.size !== undefined) {
|
|
53
|
+
const { width, height, ...extra } = isObject(spec.window.size) ? spec.window.size : { width: undefined };
|
|
54
|
+
if (Object.keys(extra).length) throw unsupported(`DMG spec: unsupported field window.size.${Object.keys(extra)[0]}`);
|
|
55
|
+
options.window = [int(width, "DMG spec: window.size.width", { min: 1 }), int(height, "DMG spec: window.size.height", { min: 1 })];
|
|
56
|
+
}
|
|
57
|
+
if (spec.window.position !== undefined) {
|
|
58
|
+
const { x, y, ...extra } = isObject(spec.window.position) ? spec.window.position : { x: undefined };
|
|
59
|
+
if (Object.keys(extra).length) throw unsupported(`DMG spec: unsupported field window.position.${Object.keys(extra)[0]}`);
|
|
60
|
+
options.windowPosition = [int(x, "DMG spec: window.position.x"), int(y, "DMG spec: window.position.y")];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (spec.contents !== undefined) {
|
|
64
|
+
if (!Array.isArray(spec.contents)) throw fail("DMG spec: contents must be an array");
|
|
65
|
+
const appName = app ? path.basename(app) : null;
|
|
66
|
+
spec.contents.forEach((item, index) => {
|
|
67
|
+
const label = `DMG spec: contents[${index}]`;
|
|
68
|
+
if (!isObject(item)) throw fail(`${label} must be an object`);
|
|
69
|
+
for (const key of Object.keys(item)) if (!["x", "y", "type", "path", "name"].includes(key)) throw unsupported(`${label}: unsupported field ${JSON.stringify(key)}`);
|
|
70
|
+
const point = [int(item.x, `${label}.x`), int(item.y, `${label}.y`)];
|
|
71
|
+
if (typeof item.path !== "string" || !item.path) throw fail(`${label}.path must be a non-empty string`);
|
|
72
|
+
if (item.type === "link") {
|
|
73
|
+
if (item.path !== "/Applications") throw unsupported(`${label}: only a link to /Applications is supported (got ${item.path})`);
|
|
74
|
+
if (options.applicationsPosition) throw fail(`${label}: duplicate /Applications link`);
|
|
75
|
+
options.applicationsPosition = point;
|
|
76
|
+
} else if (item.type === "file" || item.type === undefined) {
|
|
77
|
+
const name = item.name ?? path.basename(item.path);
|
|
78
|
+
if (!name.endsWith(".app")) throw unsupported(`${label}: only the .app can be a file entry (got ${item.path}); extra files are not supported`);
|
|
79
|
+
if (appName && name !== appName) throw fail(`${label}: names ${name} but the app being packed is ${appName}`);
|
|
80
|
+
if (options.appPosition) throw fail(`${label}: duplicate .app entry`);
|
|
81
|
+
options.appPosition = point;
|
|
82
|
+
} else if (item.type === "position") throw unsupported(`${label}: position entries are not supported`);
|
|
83
|
+
else throw fail(`${label}.type must be "file" or "link" (got ${JSON.stringify(item.type)})`);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return options;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read and parse a spec file. */
|
|
90
|
+
export function readDmgSpec(specPath, { app } = {}) {
|
|
91
|
+
const file = path.resolve(specPath);
|
|
92
|
+
let text;
|
|
93
|
+
try { text = readFileSync(file, "utf8"); } catch (error) { throw fail(`DMG spec not readable: ${file} (${error.code ?? error.message})`); }
|
|
94
|
+
let spec;
|
|
95
|
+
try { spec = JSON.parse(text); } catch { throw fail(`DMG spec is not valid JSON: ${file}`); }
|
|
96
|
+
return parseDmgSpec(spec, { baseDir: path.dirname(file), app });
|
|
97
|
+
}
|
package/execution-policy.mjs
CHANGED
|
@@ -37,9 +37,25 @@ export function releaseExecutionPolicy(cwd = process.cwd()) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export const LOCAL_BUILD_FILE = ".rightkit-local-build.json";
|
|
41
|
+
|
|
42
|
+
// Adrian's local-build opt-in (2026-10-14): a committed .rightkit-local-build.json naming this exact
|
|
43
|
+
// repository allows local build, test and publish. Mirrors the build-control hook's check.
|
|
44
|
+
function localBuildGranted(root) {
|
|
45
|
+
if (!root) return false;
|
|
46
|
+
try {
|
|
47
|
+
const grant = JSON.parse(readFileSync(path.join(root, LOCAL_BUILD_FILE), "utf8"));
|
|
48
|
+
if (grant?.allow !== "build-test-publish" || typeof grant.repository !== "string") return false;
|
|
49
|
+
const config = readFileSync(path.join(root, ".git", "config"), "utf8");
|
|
50
|
+
const origin = /\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/i.exec(config)?.[1] ?? "";
|
|
51
|
+
const repo = /github\.com[:/]([^/]+\/[^/.\s]+?)(?:\.git)?\/?$/i.exec(origin)?.[1];
|
|
52
|
+
return !!repo && repo.toLowerCase() === grant.repository.toLowerCase();
|
|
53
|
+
} catch { return false; }
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
export function assertReleaseExecutionAllowed({ cwd = process.cwd(), environment = process.env, command = "build" } = {}) {
|
|
41
57
|
const policy = releaseExecutionPolicy(cwd);
|
|
42
|
-
if (policy.compile === GITHUB_ACTIONS_ONLY && environment.GITHUB_ACTIONS !== "true") {
|
|
58
|
+
if (policy.compile === GITHUB_ACTIONS_ONLY && environment.GITHUB_ACTIONS !== "true" && !localBuildGranted(policy.root)) {
|
|
43
59
|
throw Object.assign(new Error(`${command} is GitHub Actions only for ${policy.root}; push changes & use managed CI`), { code: GITHUB_ACTIONS_ONLY_ERROR });
|
|
44
60
|
}
|
|
45
61
|
return policy;
|