@rightkit/release 0.2.173 → 0.3.0
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 +188 -5
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/cli/right-release.mjs +34 -6
- package/command-spec.mjs +80 -0
- package/deps-cargo.mjs +55 -0
- package/deps.mjs +7 -0
- package/development-signing.mjs +135 -4
- package/development.mjs +12 -11
- package/dist-scan.mjs +102 -0
- package/dmg-build.mjs +53 -4
- package/icon-pack.mjs +117 -0
- package/in-place-install.mjs +32 -7
- package/init-command.mjs +116 -0
- package/install-app-windows.mjs +108 -17
- package/install-app.mjs +68 -11
- package/install-tauri-devid.mjs +106 -47
- package/local-publish.mjs +317 -29
- package/mac-bundle-signing.mjs +143 -1
- package/mac-signing-matrix.mjs +73 -6
- package/macho-approval-hash.mjs +181 -0
- package/macos-signing-session.mjs +12 -2
- package/managed-cargo.mjs +49 -1
- package/media-sidecar.mjs +20 -4
- package/mirror-root-artifact.mjs +69 -21
- package/npm-license-notices.mjs +159 -0
- package/ownership-dependency-allowlist.json +1 -0
- package/ownership.mjs +37 -4
- package/package-recipe-targets.mjs +180 -16
- package/package.json +1 -1
- package/pe-content.mjs +133 -0
- package/portable-development.mjs +9 -9
- package/publication-gate.mjs +70 -3
- package/publish-cargo.mjs +26 -7
- package/qa-build-features.mjs +7 -1
- package/recipe-build.mjs +346 -0
- package/release-dependencies-command.mjs +45 -0
- package/release-dependencies.mjs +60 -0
- package/release-switch-scan.mjs +17 -7
- package/release.mjs +98 -12
- package/rightkit-ownership.json +46 -2
- package/rightkit-signing-agent.mjs +10 -4
- package/rightkit-versions.json +11 -6
- package/runtime-budgets.mjs +28 -12
- package/stage-binary.mjs +1 -1
- package/stage-sidecar-command.mjs +36 -4
- package/stage-sidecar.mjs +926 -74
- package/test-command.mjs +190 -0
- package/toolchain-swift.mjs +239 -0
- package/web-build.mjs +67 -0
package/mac-bundle-signing.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Directory entries (.app bundles) in `sign.prePackageFiles`: sign nested code inside-out first
|
|
2
|
-
// (no entitlements), then the bundle itself, reusing the swift-mac-app inside-out discovery.
|
|
2
|
+
// (no entitlements unless declared per path), then the bundle itself, reusing the swift-mac-app inside-out discovery.
|
|
3
3
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { discoverNestedCode } from "./swift-mac-app.mjs";
|
|
@@ -31,3 +31,145 @@ export function expandMacSigningEntries(files, { isDirectory = (f) => statSync(f
|
|
|
31
31
|
}
|
|
32
32
|
return { nested: [...new Set(nested)], files };
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
36
|
+
// Declarative per-binary signing settings, shared by the dev installer (development-signing.mjs) and release nested code.
|
|
37
|
+
//
|
|
38
|
+
// identifiers: { "<bundle-relative path or glob>": "<--identifier value>" }
|
|
39
|
+
// identifierTemplate: "<bundleId>.runtime.<relpath>" (default identifier for the remaining plain Mach-O files)
|
|
40
|
+
// entitlements: { "<bundle-relative path or glob>": "<entitlements plist path>" }
|
|
41
|
+
//
|
|
42
|
+
// Paths are relative to the .app bundle with `/` separators (`Contents/MacOS/engine`); "." names the bundle itself.
|
|
43
|
+
// An exact key beats a glob; among globs the first declared wins. `*` and `?` stay inside one path segment, `**` crosses
|
|
44
|
+
// segments. Nothing declared means nothing changes: no `--identifier`, no entitlements (release nested code stays unentitled).
|
|
45
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** A codesign `--identifier` value. Must not start with `-` (argv safety). */
|
|
48
|
+
export const SIGNING_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.-]{0,254}$/; // same charset as release validation and the signing agent
|
|
49
|
+
const TEMPLATE_TOKENS = new Set(["bundleId", "relpath", "basename"]);
|
|
50
|
+
const BUNDLE_DIRECTORY = /\.(app|framework|xpc|appex|bundle)$/;
|
|
51
|
+
const sanitizePart = (value) => String(value).replace(/[^A-Za-z0-9.-]+/g, ".");
|
|
52
|
+
|
|
53
|
+
export const isGlobPattern = (pattern) => /[*?]/.test(pattern);
|
|
54
|
+
|
|
55
|
+
export function globToRegExp(glob) {
|
|
56
|
+
let out = "";
|
|
57
|
+
for (let i = 0; i < glob.length; i++) {
|
|
58
|
+
const c = glob[i];
|
|
59
|
+
if (c === "*") {
|
|
60
|
+
if (glob[i + 1] === "*") {
|
|
61
|
+
if (glob[i + 2] === "/") { out += "(?:.*/)?"; i += 2; } else { out += ".*"; i += 1; }
|
|
62
|
+
} else out += "[^/]*";
|
|
63
|
+
} else if (c === "?") out += "[^/]";
|
|
64
|
+
else out += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
65
|
+
}
|
|
66
|
+
return new RegExp(`^${out}$`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Bundle-relative POSIX path of `file` inside `app`; "" for the bundle itself. */
|
|
70
|
+
export const bundleRelativePath = (app, file) => path.relative(app, file).split(path.sep).join("/");
|
|
71
|
+
|
|
72
|
+
export function validateBundleRelativeKey(key, label, reject) {
|
|
73
|
+
if (typeof key !== "string" || !key.trim() || key !== key.trim()) reject(`${label}: keys must be non-empty bundle-relative paths`);
|
|
74
|
+
if (key !== "." && (key.startsWith("/") || /^[A-Za-z]:/.test(key) || key.includes("\\") || key.split("/").includes(".."))) {
|
|
75
|
+
reject(`${label}: ${JSON.stringify(key)} must be a path inside the .app bundle (relative, no "..", "/" separators)`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function validateSigningIdentifier(value, label, reject) {
|
|
80
|
+
if (typeof value !== "string" || !SIGNING_IDENTIFIER_PATTERN.test(value)) reject(`${label} must be a codesign identifier (letters, digits, "." "_" "-"; not starting with "-"), got ${JSON.stringify(value)}`);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `<relpath>` and `<basename>` are the bundle-relative path / file name with runs outside [A-Za-z0-9.-] replaced by ".". */
|
|
85
|
+
export function expandIdentifierTemplate(template, { bundleId, relpath }) {
|
|
86
|
+
return template.replace(/<(bundleId|relpath|basename)>/g, (_, token) => {
|
|
87
|
+
if (token === "bundleId") {
|
|
88
|
+
if (!bundleId) throw fail("identifier template uses <bundleId> but the bundle identifier is unknown");
|
|
89
|
+
return bundleId;
|
|
90
|
+
}
|
|
91
|
+
return sanitizePart(token === "relpath" ? relpath : path.posix.basename(relpath));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateIdentifierTemplate(template, label, reject) {
|
|
96
|
+
if (typeof template !== "string" || !template) reject(`${label} must be a non-empty string such as "<bundleId>.runtime.<relpath>"`);
|
|
97
|
+
const tokens = [...template.matchAll(/<([^<>]*)>/g)].map((match) => match[1]);
|
|
98
|
+
if (tokens.some((token) => !TEMPLATE_TOKENS.has(token))) reject(`${label} may only use <bundleId>, <relpath> and <basename>`);
|
|
99
|
+
if (!tokens.includes("relpath") && !tokens.includes("basename")) reject(`${label} must contain <relpath> or <basename> so each binary gets its own identifier`);
|
|
100
|
+
const rest = template.replace(/<[^<>]*>/g, "");
|
|
101
|
+
if (rest.includes("<") || rest.includes(">")) reject(`${label} has an unbalanced "<" or ">"`);
|
|
102
|
+
validateSigningIdentifier(expandIdentifierTemplate(template, { bundleId: "a.b", relpath: "Contents/x" }), `${label} (expanded)`, reject);
|
|
103
|
+
return template;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Compile `{ pattern: value }` into lookup entries. `value(raw, label)` validates and transforms each value. */
|
|
107
|
+
export function compileBundlePathMap(map, label, { reject, value = (raw) => raw }) {
|
|
108
|
+
if (map === undefined) return [];
|
|
109
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) reject(`${label} must be an object mapping bundle-relative paths or globs to values`);
|
|
110
|
+
return Object.entries(map).map(([pattern, raw]) => {
|
|
111
|
+
validateBundleRelativeKey(pattern, label, reject);
|
|
112
|
+
return { pattern, regex: isGlobPattern(pattern) ? globToRegExp(pattern) : null, value: value(raw, `${label}[${JSON.stringify(pattern)}]`) };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Exact key first, then the first matching glob. undefined when nothing matches. */
|
|
117
|
+
export function lookupBundlePath(entries, relative) {
|
|
118
|
+
const exact = entries.find((entry) => !entry.regex && entry.pattern === relative);
|
|
119
|
+
if (exact) return exact.value;
|
|
120
|
+
return entries.find((entry) => entry.regex?.test(relative))?.value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The `--identifier` for one path, or undefined (codesign then derives it as before). The map applies to anything,
|
|
125
|
+
* including "." for the bundle; the template applies only to plain Mach-O files (`plain`), never to bundles.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveBundleIdentifier({ relative, identifiers = [], template, bundleId, plain }) {
|
|
128
|
+
const explicit = lookupBundlePath(identifiers, relative || ".");
|
|
129
|
+
if (explicit !== undefined) return explicit;
|
|
130
|
+
if (!template || !plain || !relative) return undefined;
|
|
131
|
+
const expanded = expandIdentifierTemplate(template, { bundleId, relpath: relative });
|
|
132
|
+
if (!SIGNING_IDENTIFIER_PATTERN.test(expanded)) throw fail(`identifier template produced an invalid identifier for ${relative}: ${JSON.stringify(expanded)}`);
|
|
133
|
+
return expanded;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** CFBundleIdentifier of a bundle (XML plist only; undefined when unreadable). */
|
|
137
|
+
export function bundleIdentifierOf(app) {
|
|
138
|
+
try {
|
|
139
|
+
const plist = readFileSync(path.join(app, "Contents", "Info.plist"), "utf8");
|
|
140
|
+
return /<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/.exec(plist)?.[1];
|
|
141
|
+
} catch { return undefined; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Release nested-code settings from `sign.identifiers`, `sign.identifierTemplate` and a per-path `sign.entitlements` object.
|
|
146
|
+
* Returns Map<absolute nested file, { identifier?, entitlements?: absolute }> holding only files with something declared;
|
|
147
|
+
* every other file keeps today's behaviour (no entitlements, no --identifier). A string `sign.entitlements` is the
|
|
148
|
+
* app-level file and is ignored here. Paths in the map are resolved against `root`; a missing file is an error.
|
|
149
|
+
* The caller (release signing) adds the flags with `nestedSigningFlags`.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveNestedSigningSettings(nested, { app, sign = {}, bundleId = bundleIdentifierOf(app), root, exists = existsSync }) {
|
|
152
|
+
const reject = (message) => { throw fail(message); };
|
|
153
|
+
const identifiers = compileBundlePathMap(sign.identifiers, "sign.identifiers", { reject, value: (raw, label) => validateSigningIdentifier(raw, label, reject) });
|
|
154
|
+
const template = sign.identifierTemplate === undefined ? undefined : validateIdentifierTemplate(sign.identifierTemplate, "sign.identifierTemplate", reject);
|
|
155
|
+
const perPath = typeof sign.entitlements === "object" && sign.entitlements !== null && !Array.isArray(sign.entitlements) ? sign.entitlements : undefined;
|
|
156
|
+
const entitlements = compileBundlePathMap(perPath, "sign.entitlements", { reject, value: (raw, label) => {
|
|
157
|
+
if (typeof raw !== "string" || !raw.trim()) reject(`${label} must be an entitlements plist path`);
|
|
158
|
+
const file = path.resolve(root, raw);
|
|
159
|
+
if (!exists(file)) reject(`${label} entitlements file not found: ${file}`);
|
|
160
|
+
return file;
|
|
161
|
+
} });
|
|
162
|
+
const settings = new Map();
|
|
163
|
+
if (!identifiers.length && !template && !entitlements.length) return settings;
|
|
164
|
+
for (const file of nested) {
|
|
165
|
+
const relative = bundleRelativePath(app, file);
|
|
166
|
+
const identifier = resolveBundleIdentifier({ relative, identifiers, template, bundleId, plain: !BUNDLE_DIRECTORY.test(file) });
|
|
167
|
+
const entitlementsFile = lookupBundlePath(entitlements, relative);
|
|
168
|
+
if (identifier !== undefined || entitlementsFile !== undefined) {
|
|
169
|
+
settings.set(file, { ...(identifier !== undefined ? { identifier } : {}), ...(entitlementsFile !== undefined ? { entitlements: entitlementsFile } : {}) });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return settings;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const nestedSigningFlags = (settings) => [...(settings?.identifier ? ["--identifier", settings.identifier] : []), ...(settings?.entitlements ? ["--entitlements", settings.entitlements] : [])];
|
package/mac-signing-matrix.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// inside-out plan. It never runs codesign: sign-macos.mjs (via mac-sign-core.mjs) executes the plan.
|
|
12
12
|
import { existsSync, lstatSync, openSync, closeSync, readSync, realpathSync, statSync } from "node:fs";
|
|
13
13
|
import path from "node:path";
|
|
14
|
-
import { expandMacSigningEntries, mainExecutableName } from "./mac-bundle-signing.mjs";
|
|
14
|
+
import { expandMacSigningEntries, mainExecutableName, resolveNestedSigningSettings } from "./mac-bundle-signing.mjs";
|
|
15
15
|
|
|
16
16
|
const invalid = (message) => Object.assign(new Error(message), { code: "MAC_SIGN_CONFIG_INVALID" });
|
|
17
17
|
|
|
@@ -85,11 +85,57 @@ function validateNestedKey(key) {
|
|
|
85
85
|
return null;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// Per-path nested signing maps: `sign.identifiers`, `sign.identifierTemplate` and an object-valued `sign.entitlements`.
|
|
89
|
+
// Same strictness as the per-item settings above: reverse-DNS identifiers, contained relative plist paths that exist.
|
|
90
|
+
function validateNestedSigningMaps(sign, { root, errors, isFile }) {
|
|
91
|
+
const declared = {};
|
|
92
|
+
const check = (map, label, value) => {
|
|
93
|
+
if (!isPlainObject(map)) { errors.push(`${label} must be an object mapping bundle-relative paths or globs to values`); return; }
|
|
94
|
+
for (const [key, raw] of Object.entries(map)) {
|
|
95
|
+
const entry = `${label}[${JSON.stringify(key)}]`;
|
|
96
|
+
if (!key.trim() || key !== key.trim() || key.includes("\0") || key.includes("\\") || path.posix.isAbsolute(key) || /^[A-Za-z]:/.test(key) || key.split("/").includes("..")) {
|
|
97
|
+
errors.push(`${entry}: key must be a bundle-relative path (no "..", no absolute path, "/" separators)`);
|
|
98
|
+
}
|
|
99
|
+
value(raw, entry);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
if (sign.identifiers !== undefined) {
|
|
103
|
+
check(sign.identifiers, "sign.identifiers", (raw, entry) => {
|
|
104
|
+
if (typeof raw !== "string" || !IDENTIFIER_PATTERN.test(raw)) errors.push(`${entry} must be 1-255 characters of letters, digits, '.' and '-', starting with a letter or digit`);
|
|
105
|
+
});
|
|
106
|
+
declared.identifiers = sign.identifiers;
|
|
107
|
+
}
|
|
108
|
+
if (sign.identifierTemplate !== undefined) {
|
|
109
|
+
const template = sign.identifierTemplate;
|
|
110
|
+
if (typeof template !== "string" || !template) errors.push("sign.identifierTemplate must be a non-empty string such as \"<bundleId>.runtime.<relpath>\"");
|
|
111
|
+
else if (!/^[A-Za-z0-9.-]*$/.test(template.replace(/<[^<>]*>/g, ""))) errors.push("sign.identifierTemplate literal text may only contain letters, digits, '.' and '-' around <bundleId>, <relpath> and <basename>");
|
|
112
|
+
else declared.identifierTemplate = template;
|
|
113
|
+
}
|
|
114
|
+
if (isPlainObject(sign.entitlements)) {
|
|
115
|
+
check(sign.entitlements, "sign.entitlements", (raw, entry) => {
|
|
116
|
+
if (typeof raw !== "string" || !raw.trim()) { errors.push(`${entry} must be a non-empty path string`); return; }
|
|
117
|
+
if (path.isAbsolute(raw) || /^[A-Za-z]:/.test(raw) || raw.includes("\0")) { errors.push(`${entry} must be relative to the release config directory: ${raw}`); return; }
|
|
118
|
+
const resolved = path.resolve(root, raw);
|
|
119
|
+
const relative = path.relative(root, resolved);
|
|
120
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) errors.push(`${entry} must stay inside the release config directory: ${raw}`);
|
|
121
|
+
else if (!isFile(resolved)) errors.push(`${entry} entitlements file not found: ${raw}`);
|
|
122
|
+
else if (!looksLikePlist(resolved)) errors.push(`${entry} is not a property list (expected an XML or binary plist): ${raw}`);
|
|
123
|
+
});
|
|
124
|
+
declared.entitlements = sign.entitlements;
|
|
125
|
+
}
|
|
126
|
+
if (errors.length || !Object.keys(declared).length) return undefined;
|
|
127
|
+
// Delegate key globs and template tokens to the shared resolver so release and development read them identically.
|
|
128
|
+
try { resolveNestedSigningSettings([], { app: "x.app", sign: declared, bundleId: "a.b", root, exists: () => true }); }
|
|
129
|
+
catch (error) { errors.push(error.message); return undefined; }
|
|
130
|
+
return declared;
|
|
131
|
+
}
|
|
132
|
+
|
|
88
133
|
/**
|
|
89
134
|
* Validate and normalize `sign` for a macOS target. Collects every problem, then throws one
|
|
90
|
-
* MAC_SIGN_CONFIG_INVALID error. Returns { entries, nestedCode } where
|
|
91
|
-
* entries:
|
|
92
|
-
* nestedCode:
|
|
135
|
+
* MAC_SIGN_CONFIG_INVALID error. Returns { entries, nestedCode, nestedSigning? } where
|
|
136
|
+
* entries: [{ file, identifier?, entitlements?: absolute, options: [] }] (file stays as declared)
|
|
137
|
+
* nestedCode: [{ key, identifier?, entitlements?: absolute, options: [] }]
|
|
138
|
+
* nestedSigning: { identifiers?, identifierTemplate?, entitlements? } as declared; present only when one is declared
|
|
93
139
|
* Injectable: isFile.
|
|
94
140
|
*/
|
|
95
141
|
export function validateMacSigningConfig(sign, { root, isFile = (file) => existsSync(file) && statSync(file).isFile() } = {}) {
|
|
@@ -136,8 +182,10 @@ export function validateMacSigningConfig(sign, { root, isFile = (file) => exists
|
|
|
136
182
|
}
|
|
137
183
|
}
|
|
138
184
|
}
|
|
185
|
+
const nestedSigning = validateNestedSigningMaps(sign, context);
|
|
186
|
+
if (nestedSigning && !entries.some((entry) => /\.app\/?$/.test(entry.file))) errors.push("sign.identifiers, sign.identifierTemplate and per-path sign.entitlements need a .app entry in sign.prePackageFiles to resolve their paths against");
|
|
139
187
|
if (errors.length) throw invalid(`invalid macOS signing configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
|
|
140
|
-
return { entries, nestedCode };
|
|
188
|
+
return { entries, nestedCode, ...(nestedSigning ? { nestedSigning } : {}) };
|
|
141
189
|
}
|
|
142
190
|
|
|
143
191
|
/** True when an entry or nested item carries anything beyond the default (hardened runtime, no entitlements). */
|
|
@@ -154,7 +202,7 @@ const depth = (file) => file.split(path.sep).length;
|
|
|
154
202
|
* a nestedCode path is missing, is a symlink, escapes the bundle, or names the bundle's main executable.
|
|
155
203
|
* Injectable: expand, lstat, realpath, mainName.
|
|
156
204
|
*/
|
|
157
|
-
export function planMacSigning({ entries, nestedCode = [], expand = expandMacSigningEntries, lstat = lstatSync, realpath = realpathSync, mainName = mainExecutableName }) {
|
|
205
|
+
export function planMacSigning({ entries, nestedCode = [], nestedSigning, root, resolveSettings = resolveNestedSigningSettings, expand = expandMacSigningEntries, lstat = lstatSync, realpath = realpathSync, mainName = mainExecutableName }) {
|
|
158
206
|
const { nested: discovered } = expand(entries.map((entry) => entry.file));
|
|
159
207
|
const byPath = new Map(discovered.map((file) => [file, { file, options: [] }]));
|
|
160
208
|
if (nestedCode.length) {
|
|
@@ -174,6 +222,25 @@ export function planMacSigning({ entries, nestedCode = [], expand = expandMacSig
|
|
|
174
222
|
byPath.set(file, { file, ...(item.identifier ? { identifier: item.identifier } : {}), ...(item.entitlements ? { entitlements: item.entitlements } : {}), options: item.options });
|
|
175
223
|
}
|
|
176
224
|
}
|
|
225
|
+
// Per-path maps (sign.identifiers / identifierTemplate / per-path sign.entitlements) fill in only what a nestedCode item left
|
|
226
|
+
// unset. Nothing declared: this block is skipped and the items stay exactly as discovered.
|
|
227
|
+
if (nestedSigning && Object.keys(nestedSigning).length) {
|
|
228
|
+
const apps = entries.map((entry) => entry.file).filter((file) => /\.app\/?$/.test(file)).map((file) => file.replace(/\/+$/, ""));
|
|
229
|
+
for (const app of apps) {
|
|
230
|
+
const own = [...byPath.values()].filter((item) => item.file.startsWith(app + path.sep) || item.file.startsWith(`${app}/`));
|
|
231
|
+
let settings;
|
|
232
|
+
try { settings = resolveSettings(own.map((item) => item.file), { app, sign: nestedSigning, root: root ?? process.cwd() }); }
|
|
233
|
+
catch (error) { throw invalid(error.message); }
|
|
234
|
+
for (const item of own) {
|
|
235
|
+
const found = settings.get(item.file);
|
|
236
|
+
if (!found) continue;
|
|
237
|
+
const identifier = item.identifier ?? found.identifier;
|
|
238
|
+
if (identifier !== undefined && !IDENTIFIER_PATTERN.test(identifier)) throw invalid(`signing identifier for ${path.relative(app, item.file)} must be letters, digits, '.' and '-': ${JSON.stringify(identifier)}`);
|
|
239
|
+
const entitlements = item.entitlements ?? found.entitlements;
|
|
240
|
+
byPath.set(item.file, { ...item, ...(identifier !== undefined ? { identifier } : {}), ...(entitlements ? { entitlements } : {}) });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
177
244
|
const nested = [...byPath.values()].sort((a, b) => depth(b.file) - depth(a.file) || a.file.localeCompare(b.file));
|
|
178
245
|
return { nested, top: entries };
|
|
179
246
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Signing-independent approval hashes for staged prebuilt binaries (Orthic-Labs/rightkit#490).
|
|
2
|
+
//
|
|
3
|
+
// `raw-sha256` the sha256 of the file bytes (Windows and Linux binaries).
|
|
4
|
+
// `macho-codesign-strip-linkedit-v1` the sha256 of a thin little-endian 64-bit Mach-O after its code signature is removed:
|
|
5
|
+
// LC_CODE_SIGNATURE and the trailing signature blob are gone, the __LINKEDIT filesize is
|
|
6
|
+
// whatever `codesign --remove-signature` leaves, and the __LINKEDIT vmsize is normalised to
|
|
7
|
+
// the page-rounded filesize, so a signed and an unsigned copy of the same build hash alike.
|
|
8
|
+
//
|
|
9
|
+
// This is the algorithm GenRight's `local-image-approval-hash.mjs` recorded as `approval_hash_algorithm` in its runtime
|
|
10
|
+
// manifest, moved here so apps stop carrying it. Every layout surprise (a fat or big-endian file, a duplicate or non-terminal
|
|
11
|
+
// signature, a load command that changed, padding that is not zero) is a typed refusal, never a guess. The signature is removed
|
|
12
|
+
// from a private copy with `/usr/bin/codesign --remove-signature`; the input file is never modified. macOS only for Mach-O.
|
|
13
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
14
|
+
import { chmodSync, lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { spawnSync } from "./background-process.mjs";
|
|
18
|
+
|
|
19
|
+
export const APPROVAL_RAW_SHA256 = "raw-sha256";
|
|
20
|
+
export const APPROVAL_MACHO_STRIP_LINKEDIT_V1 = "macho-codesign-strip-linkedit-v1";
|
|
21
|
+
export const APPROVAL_ALGORITHMS = Object.freeze([APPROVAL_RAW_SHA256, APPROVAL_MACHO_STRIP_LINKEDIT_V1]);
|
|
22
|
+
|
|
23
|
+
const LC_CODE_SIGNATURE = 0x1d;
|
|
24
|
+
const LC_SEGMENT_64 = 0x19;
|
|
25
|
+
const MH_MAGIC_64 = 0xfeedfacf;
|
|
26
|
+
const CPU_TYPE_X86_64 = 0x01000007;
|
|
27
|
+
const CPU_TYPE_ARM64 = 0x0100000c;
|
|
28
|
+
|
|
29
|
+
export class ApprovalHashError extends Error {
|
|
30
|
+
constructor(code, message) { super(message); this.name = "ApprovalHashError"; this.code = code; }
|
|
31
|
+
}
|
|
32
|
+
const fail = (message, code = "APPROVAL_HASH_INVALID_MACHO") => { throw new ApprovalHashError(code, `backend integrity: ${message}`); };
|
|
33
|
+
|
|
34
|
+
/** Parse a thin 64-bit little-endian Mach-O. Exported for tests. */
|
|
35
|
+
export function parseMachO(bytes) {
|
|
36
|
+
if (bytes.length < 32 || bytes.readUInt32LE(0) !== MH_MAGIC_64) fail("only little-endian 64-bit thin Mach-O is supported");
|
|
37
|
+
const cpuType = bytes.readUInt32LE(4);
|
|
38
|
+
if (![CPU_TYPE_X86_64, CPU_TYPE_ARM64].includes(cpuType)) fail("unsupported Mach-O CPU type");
|
|
39
|
+
const ncmds = bytes.readUInt32LE(16);
|
|
40
|
+
const sizeofcmds = bytes.readUInt32LE(20);
|
|
41
|
+
const commandEnd = 32 + sizeofcmds;
|
|
42
|
+
if (ncmds > 65_536 || commandEnd > bytes.length || sizeofcmds > bytes.length / 2) fail("invalid Mach-O load command table");
|
|
43
|
+
let cursor = 32;
|
|
44
|
+
let signature = null;
|
|
45
|
+
let linkedit = null;
|
|
46
|
+
const normalizedCommands = [];
|
|
47
|
+
for (let index = 0; index < ncmds; index += 1) {
|
|
48
|
+
if (cursor + 8 > commandEnd) fail("truncated Mach-O load command");
|
|
49
|
+
const command = bytes.readUInt32LE(cursor);
|
|
50
|
+
const commandSize = bytes.readUInt32LE(cursor + 4);
|
|
51
|
+
if (commandSize < 8 || commandSize % 4 !== 0 || cursor + commandSize > commandEnd) fail("invalid Mach-O load command size");
|
|
52
|
+
const commandBytes = Buffer.from(bytes.subarray(cursor, cursor + commandSize));
|
|
53
|
+
if (command === LC_CODE_SIGNATURE) {
|
|
54
|
+
if (commandSize !== 16 || signature) fail("invalid duplicate code signature command");
|
|
55
|
+
const offset = bytes.readUInt32LE(cursor + 8);
|
|
56
|
+
const size = bytes.readUInt32LE(cursor + 12);
|
|
57
|
+
if (!size || offset + size > bytes.length) fail("invalid code signature extent");
|
|
58
|
+
signature = { offset, size };
|
|
59
|
+
} else if (command === LC_SEGMENT_64) {
|
|
60
|
+
if (commandSize < 72) fail("truncated Mach-O segment command");
|
|
61
|
+
const sectionCount = commandBytes.readUInt32LE(64);
|
|
62
|
+
if (sectionCount > 65_536 || 72 + sectionCount * 80 > commandSize) fail("invalid Mach-O segment sections");
|
|
63
|
+
const segmentName = commandBytes.subarray(8, 24).toString("utf8").replace(/\0.*$/s, "");
|
|
64
|
+
if (segmentName === "__LINKEDIT") {
|
|
65
|
+
if (linkedit) fail("duplicate __LINKEDIT segment");
|
|
66
|
+
const vmsize = commandBytes.readBigUInt64LE(32);
|
|
67
|
+
const fileoff = commandBytes.readBigUInt64LE(40);
|
|
68
|
+
const filesize = commandBytes.readBigUInt64LE(48);
|
|
69
|
+
if (fileoff + filesize > BigInt(bytes.length) || vmsize < filesize) fail("invalid __LINKEDIT extent");
|
|
70
|
+
// vmsize and filesize legitimately differ after the signature is removed: they are normalised, never compared raw.
|
|
71
|
+
commandBytes.fill(0, 32, 40);
|
|
72
|
+
commandBytes.fill(0, 48, 56);
|
|
73
|
+
linkedit = { fileoff, filesize, vmsize, vmsizeOffset: cursor + 32 };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (command !== LC_CODE_SIGNATURE) normalizedCommands.push(commandBytes);
|
|
77
|
+
cursor += commandSize;
|
|
78
|
+
}
|
|
79
|
+
if (cursor !== commandEnd) fail("load commands do not fill declared table");
|
|
80
|
+
if (!linkedit) fail("missing __LINKEDIT segment");
|
|
81
|
+
if (signature && (BigInt(signature.offset) < linkedit.fileoff
|
|
82
|
+
|| signature.offset + signature.size !== bytes.length
|
|
83
|
+
|| linkedit.fileoff + linkedit.filesize !== BigInt(bytes.length))) {
|
|
84
|
+
fail("signature is not terminal __LINKEDIT data");
|
|
85
|
+
}
|
|
86
|
+
return { bytes, cpuType, ncmds, sizeofcmds, signature, linkedit, normalizedCommands };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every byte outside the explicitly permitted ranges must be identical between the captured original and the stripped copy.
|
|
91
|
+
* Permitted: header ncmds/sizeofcmds (checked by the caller), the load-command table (compared command by command, with only
|
|
92
|
+
* the signature command removed and __LINKEDIT vmsize/filesize normalised), the zeroed tail the shorter table leaves, and the
|
|
93
|
+
* signature blob plus its alignment padding at the end of __LINKEDIT. Code, data and all __LINKEDIT content before the
|
|
94
|
+
* signature are compared byte for byte.
|
|
95
|
+
*/
|
|
96
|
+
function assertOnlySignatureDiffers(before, after) {
|
|
97
|
+
const a = before.bytes;
|
|
98
|
+
const b = after.bytes;
|
|
99
|
+
if (!before.signature) {
|
|
100
|
+
if (a.length !== b.length || !a.equals(b)) fail("unsigned Mach-O changed during normalization");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const same = (from, to, label) => {
|
|
104
|
+
if (from === to) return;
|
|
105
|
+
if (!a.subarray(from, to).equals(b.subarray(from, to))) fail(`signature removal changed ${label}`);
|
|
106
|
+
};
|
|
107
|
+
const beforeTable = 32 + before.sizeofcmds;
|
|
108
|
+
const afterTable = 32 + after.sizeofcmds;
|
|
109
|
+
same(0, 16, "Mach-O header");
|
|
110
|
+
same(24, 32, "Mach-O header");
|
|
111
|
+
if (b.subarray(afterTable, beforeTable).some((byte) => byte !== 0)) fail("signature removal left nonzero bytes after the load commands");
|
|
112
|
+
const keptEnd = Number(after.linkedit.fileoff + after.linkedit.filesize);
|
|
113
|
+
if (b.length !== keptEnd) fail("stripped Mach-O does not end at __LINKEDIT");
|
|
114
|
+
same(beforeTable, keptEnd, "executable or data bytes");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The approval digest of an already-unsigned copy and its signed original: verifies that signature removal changed
|
|
119
|
+
* nothing but the signature, then hashes the unsigned bytes with the normalised __LINKEDIT vmsize. Exported for tests.
|
|
120
|
+
*/
|
|
121
|
+
export function machoApprovalDigest(beforeBytes, afterBytes) {
|
|
122
|
+
const before = parseMachO(beforeBytes);
|
|
123
|
+
const after = parseMachO(Buffer.from(afterBytes));
|
|
124
|
+
if (after.signature) fail("signature remains after normalization");
|
|
125
|
+
if (before.signature && (before.ncmds !== after.ncmds + 1 || before.sizeofcmds !== after.sizeofcmds + 16)) fail("signature removal changed Mach-O command layout");
|
|
126
|
+
if (before.signature) {
|
|
127
|
+
const beforeLinkeditEnd = before.linkedit.fileoff + before.linkedit.filesize;
|
|
128
|
+
const afterLinkeditEnd = after.linkedit.fileoff + after.linkedit.filesize;
|
|
129
|
+
const removedPadding = BigInt(before.signature.offset) - afterLinkeditEnd;
|
|
130
|
+
if (beforeLinkeditEnd !== BigInt(before.signature.offset + before.signature.size)
|
|
131
|
+
|| after.linkedit.fileoff !== before.linkedit.fileoff
|
|
132
|
+
|| afterLinkeditEnd > BigInt(before.signature.offset)
|
|
133
|
+
|| removedPadding < 0n || removedPadding > 15n
|
|
134
|
+
|| BigInt(after.bytes.length) !== afterLinkeditEnd) {
|
|
135
|
+
fail("code signature is outside trailing __LINKEDIT data");
|
|
136
|
+
}
|
|
137
|
+
const start = Number(afterLinkeditEnd);
|
|
138
|
+
const end = before.signature.offset;
|
|
139
|
+
if (before.bytes.subarray(start, end).some((byte) => byte !== 0)) fail("nonzero alignment padding before code signature");
|
|
140
|
+
}
|
|
141
|
+
assertOnlySignatureDiffers(before, after);
|
|
142
|
+
if (before.normalizedCommands.length !== after.normalizedCommands.length
|
|
143
|
+
|| before.normalizedCommands.some((command, index) => !command.equals(after.normalizedCommands[index]))) {
|
|
144
|
+
fail("signature removal changed non-signature load commands");
|
|
145
|
+
}
|
|
146
|
+
const pageSize = before.cpuType === CPU_TYPE_ARM64 ? 16n * 1024n : 4096n;
|
|
147
|
+
const roundedSize = ((after.linkedit.filesize + pageSize - 1n) / pageSize) * pageSize;
|
|
148
|
+
if (after.linkedit.vmsize < roundedSize) fail("invalid __LINKEDIT virtual size");
|
|
149
|
+
after.bytes.writeBigUInt64LE(roundedSize, after.linkedit.vmsizeOffset);
|
|
150
|
+
return createHash("sha256").update(after.bytes).digest("hex");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Default signature removal: `/usr/bin/codesign --remove-signature <copy>` (macOS only). */
|
|
154
|
+
function removeSignatureWithCodesign(copy) {
|
|
155
|
+
const result = spawnSync("/usr/bin/codesign", ["--remove-signature", copy], { stdio: "ignore", timeout: 15_000, windowsHide: true });
|
|
156
|
+
if (result.error || result.status !== 0) throw new ApprovalHashError("APPROVAL_HASH_CODESIGN_FAILED", `backend integrity: codesign normalization failed (${result.error?.message ?? result.status})`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The approval digest of `file` under `algorithm`. `removeSignature(copyPath)` is injectable for tests and for hosts that
|
|
161
|
+
* strip signatures another way; the default needs macOS. The input file is never modified.
|
|
162
|
+
*/
|
|
163
|
+
export function approvalDigest(file, algorithm, { platform = process.platform, removeSignature = removeSignatureWithCodesign, tmpdir = os.tmpdir() } = {}) {
|
|
164
|
+
if (algorithm === APPROVAL_RAW_SHA256) return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
165
|
+
if (algorithm !== APPROVAL_MACHO_STRIP_LINKEDIT_V1) throw new ApprovalHashError("APPROVAL_HASH_ALGORITHM_UNKNOWN", `unknown approval hash algorithm ${JSON.stringify(algorithm)} (supported: ${APPROVAL_ALGORITHMS.join(", ")})`);
|
|
166
|
+
if (platform !== "darwin") throw new ApprovalHashError("APPROVAL_HASH_HOST_UNSUPPORTED", `${APPROVAL_MACHO_STRIP_LINKEDIT_V1} needs macOS (codesign); this host is ${platform}`);
|
|
167
|
+
if (!lstatSync(file).isFile()) fail("expected regular Mach-O file");
|
|
168
|
+
const original = readFileSync(file);
|
|
169
|
+
const before = parseMachO(original);
|
|
170
|
+
const directory = mkdtempSync(path.join(tmpdir, `rightkit-macho-${process.pid}-${randomBytes(8).toString("hex")}-`), { mode: 0o700 });
|
|
171
|
+
const copy = path.join(directory, "binary");
|
|
172
|
+
try {
|
|
173
|
+
// The copy is written from the captured bytes, never re-read from the path, so the validated snapshot is the one stripped.
|
|
174
|
+
writeFileSync(copy, original, { mode: 0o700 });
|
|
175
|
+
chmodSync(copy, 0o700);
|
|
176
|
+
if (before.signature) removeSignature(copy);
|
|
177
|
+
return machoApprovalDigest(original, readFileSync(copy));
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(directory, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -5,7 +5,7 @@ import { spawnSync } from "./background-process.mjs";
|
|
|
5
5
|
|
|
6
6
|
import { requestSigningAgentSync, validateNotaryAgentReport } from "./signing-agent-client.mjs";
|
|
7
7
|
import { installSigningAgent, requireMacGuiSession } from "./signing-agent-install.mjs";
|
|
8
|
-
import { signingAgentPaths, defaultSigningRoots, ensurePrivateDirectory, privatePath, within, NOTARIZATION_TIMEOUT_MS, MAX_NOTARIZATION_TIMEOUT_MS } from "./rightkit-signing-agent.mjs";
|
|
8
|
+
import { signingAgentPaths, defaultSigningRoots, ensurePrivateDirectory, privatePath, within, NOTARIZATION_TIMEOUT_MS, MAX_NOTARIZATION_TIMEOUT_MS, AGENT_IDENTIFIER_PATTERN, CODESIGN_IDENTIFIER_SCHEMA } from "./rightkit-signing-agent.mjs";
|
|
9
9
|
import { refuseManagedEnvSecrets } from "./managed-host-secrets.mjs";
|
|
10
10
|
|
|
11
11
|
export class MacSigningSessionError extends Error {
|
|
@@ -48,11 +48,13 @@ export function codesignAgentItems(commands) {
|
|
|
48
48
|
else if (option === "--timestamp" && item.timestamp === undefined) item.timestamp = true;
|
|
49
49
|
else if (option === "--options" && args[i + 1] === "runtime" && item.runtime === undefined) { item.runtime = true; i++; }
|
|
50
50
|
else if (option === "--sign" && item.identity === undefined) item.identity = args[++i];
|
|
51
|
+
else if (option === "--identifier" && item.identifier === undefined) item.identifier = args[++i];
|
|
51
52
|
else if (option === "--entitlements" && item.entitlements === undefined) item.entitlements = args[++i];
|
|
52
53
|
else if (option === "--preserve-metadata=entitlements" && item.preserveEntitlements === undefined) item.preserveEntitlements = true;
|
|
53
54
|
else throw new TypeError(`Unsupported agent codesign option: ${option}`);
|
|
54
55
|
}
|
|
55
56
|
item.path = args.at(-1);
|
|
57
|
+
if (item.identifier !== undefined && !AGENT_IDENTIFIER_PATTERN.test(item.identifier)) throw new TypeError("Agent signing requires a reverse-DNS codesign identifier");
|
|
56
58
|
if (!item.identity || item.identity === "-" || !path.isAbsolute(item.path ?? "")
|
|
57
59
|
|| (item.entitlements !== undefined && !path.isAbsolute(item.entitlements))) throw new TypeError("Agent signing requires real identity & absolute paths");
|
|
58
60
|
return item;
|
|
@@ -76,6 +78,8 @@ export function runMacGuiSigning({ kind, commands, files, cwd = process.cwd(), e
|
|
|
76
78
|
try {
|
|
77
79
|
if (kind === "codesign") {
|
|
78
80
|
plan.items = codesignAgentItems(commands);
|
|
81
|
+
// Only a request that carries a typed identifier uses schema 2; every other codesign request stays schema 1.
|
|
82
|
+
if (plan.items.some((item) => item.identifier !== undefined)) plan.schema = CODESIGN_IDENTIFIER_SCHEMA;
|
|
79
83
|
// Development signing generates entitlements in TMPDIR. Copy only those
|
|
80
84
|
// plist bytes into managed private staging, never broaden signing roots.
|
|
81
85
|
for (const item of plan.items) {
|
|
@@ -99,7 +103,13 @@ export function runMacGuiSigning({ kind, commands, files, cwd = process.cwd(), e
|
|
|
99
103
|
let report;
|
|
100
104
|
try { report = request(plan, clientOptions); }
|
|
101
105
|
catch (error) {
|
|
102
|
-
|
|
106
|
+
let missing = error.code === "MAC_SIGNING_AGENT_UNAVAILABLE";
|
|
107
|
+
// An installed agent that predates typed identifiers refuses schema 2: upgrade it once, never loop on a rejected request.
|
|
108
|
+
if (!missing && plan.schema === CODESIGN_IDENTIFIER_SCHEMA && error.code === "MAC_SIGNING_AGENT_PROTOCOL") {
|
|
109
|
+
const health = request({ schema: 1, type: "health" }, { home, timeoutMs: 1500 });
|
|
110
|
+
missing = !health.capabilities?.includes("codesign-identifier");
|
|
111
|
+
}
|
|
112
|
+
if (!missing) throw error;
|
|
103
113
|
requireMacGuiSession({ run, uid });
|
|
104
114
|
install({ home, username, uid, run, io, managedRoot: env.RIGHTKIT_MANAGED_ROOT });
|
|
105
115
|
report = request(plan, clientOptions); // One install attempt, one retry.
|
package/managed-cargo.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { spawnSync } from "./background-process.mjs";
|
|
6
|
+
import { spawn as spawnProcess, spawnSync } from "./background-process.mjs";
|
|
7
7
|
|
|
8
8
|
export function managedRuntimeClient(env = process.env, platform = process.platform, exists = existsSync) {
|
|
9
9
|
const roots = managedControlCandidates({ env, platform });
|
|
@@ -72,3 +72,51 @@ export function runCargoSync(args, options = {}, { platform = process.platform,
|
|
|
72
72
|
function unavailable(message) {
|
|
73
73
|
return { status: null, stdout: "", stderr: message, error: Object.assign(new Error(message), { code: 'RK_BROKER_UNAVAILABLE' }) };
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Async `spawnSync` look-alike for the batch publisher: runs the command through the release spawn boundary
|
|
78
|
+
* (background-process.mjs), collects stdout and stderr, and resolves `{ status, signal, stdout, stderr, error }`.
|
|
79
|
+
* It never rejects: a spawn failure, a timeout or an exceeded `maxBuffer` resolves with `error` set, which is
|
|
80
|
+
* exactly how callers already read a failed `spawnSync`.
|
|
81
|
+
*/
|
|
82
|
+
export function spawnCollect(command, args, options = {}, { spawnAsync = spawnProcess } = {}) {
|
|
83
|
+
const { timeout, maxBuffer = 512 * 1024 * 1024, encoding: _encoding, stdio: _stdio, ...rest } = options;
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
let child;
|
|
86
|
+
try { child = spawnAsync(command, args, { ...rest, stdio: ["ignore", "pipe", "pipe"] }); } catch (error) { resolve({ status: null, signal: null, stdout: "", stderr: "", error }); return; }
|
|
87
|
+
const out = []; const err = [];
|
|
88
|
+
let size = 0; let settled = false; let timer; let failure;
|
|
89
|
+
const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); resolve(result); };
|
|
90
|
+
const take = (chunks) => (chunk) => {
|
|
91
|
+
size += chunk.length;
|
|
92
|
+
if (size > maxBuffer) { failure ??= new Error(`output exceeded maxBuffer (${maxBuffer} bytes)`); child.kill("SIGKILL"); return; }
|
|
93
|
+
chunks.push(chunk);
|
|
94
|
+
};
|
|
95
|
+
child.stdout.on("data", take(out));
|
|
96
|
+
child.stderr.on("data", take(err));
|
|
97
|
+
child.on("error", (error) => finish({ status: null, signal: null, stdout: "", stderr: "", error }));
|
|
98
|
+
child.on("close", (status, signal) => finish({
|
|
99
|
+
status, signal, stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8"), error: failure,
|
|
100
|
+
}));
|
|
101
|
+
if (timeout) timer = setTimeout(() => { failure ??= new Error(`timed out after ${timeout}ms`); child.kill("SIGKILL"); }, timeout);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Async twin of `runCargoSync`: the same client, broker-socket and darwin-never-direct selection (it asks
|
|
107
|
+
* `runCargoSync` to choose, with a spawn that only records the invocation), then runs the chosen invocation
|
|
108
|
+
* without blocking the event loop so independent crates can publish concurrently. The broker still
|
|
109
|
+
* serializes the builds themselves.
|
|
110
|
+
*/
|
|
111
|
+
export async function runCargoAsync(args, options = {}, deps = {}) {
|
|
112
|
+
const { spawnAsync = spawnCollect, ...syncDeps } = deps;
|
|
113
|
+
let chosen;
|
|
114
|
+
const probe = runCargoSync(args, options, { ...syncDeps, spawn: (command, commandArgs, commandOptions) => { chosen = { command, args: commandArgs, options: commandOptions }; return { status: 0, stdout: "", stderr: "" }; } });
|
|
115
|
+
if (!chosen) return probe;
|
|
116
|
+
const result = await spawnAsync(chosen.command, chosen.args, chosen.options);
|
|
117
|
+
const platform = syncDeps.platform ?? process.platform;
|
|
118
|
+
if (platform === 'darwin' && result.status !== 0 && /RK_BROKER_UNAVAILABLE|RightKit broker (?:unavailable|endpoint is not configured)/i.test(String(result.stderr ?? ''))) {
|
|
119
|
+
return { ...result, error: unavailable(String(result.stderr).trim()).error };
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
package/media-sidecar.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// targets.mac.mediaSidecar = {
|
|
4
4
|
// arch: "aarch64", // catalog arch (default: build host); one arch per build
|
|
5
5
|
// variant: "decode", // catalog variant (default "decode")
|
|
6
|
+
// variants: { ffmpeg: "export-ass" }, // optional per-tool variant override (a tool not named uses `variant`)
|
|
6
7
|
// tools: ["ffmpeg", "ffprobe"], // default both
|
|
7
8
|
// stageDir: "src-tauri/binaries", // Tauri externalBin directory (default)
|
|
8
9
|
// bundleDir: "Contents/MacOS", // mac only: where Tauri puts externalBin (or "Contents/Resources")
|
|
@@ -26,7 +27,7 @@ export const MEDIA_SIDECAR_TOOLS = Object.freeze(["ffmpeg", "ffprobe"]);
|
|
|
26
27
|
const PLATFORM_OS = Object.freeze({ mac: "macos", win: "windows", linux: "linux" });
|
|
27
28
|
const ARCHES = new Set(["aarch64", "x86_64"]);
|
|
28
29
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
29
|
-
const CONFIG_KEYS = new Set(["arch", "variant", "tools", "stageDir", "bundleDir", "notices", "catalog", "stage"]);
|
|
30
|
+
const CONFIG_KEYS = new Set(["arch", "variant", "variants", "tools", "stageDir", "bundleDir", "notices", "catalog", "stage"]);
|
|
30
31
|
const BUNDLE_DIRS = new Set(["Contents/MacOS", "Contents/Resources"]);
|
|
31
32
|
|
|
32
33
|
const invalid = (code, message) => Object.assign(new Error(message), { code });
|
|
@@ -87,6 +88,18 @@ export function validateMediaSidecarConfig(raw, { platform, hostArch = hostCatal
|
|
|
87
88
|
if (!Array.isArray(tools) || !tools.length || new Set(tools).size !== tools.length || tools.some((tool) => !MEDIA_SIDECAR_TOOLS.includes(tool))) {
|
|
88
89
|
errors.push(`tools must be a non-empty list without duplicates drawn from ${MEDIA_SIDECAR_TOOLS.join(", ")}`);
|
|
89
90
|
}
|
|
91
|
+
// One entry may mix variants (for example the export-ass ffmpeg with the decode ffprobe): `variants` maps a tool to its variant.
|
|
92
|
+
let variants;
|
|
93
|
+
if (raw.variants !== undefined) {
|
|
94
|
+
if (!isObject(raw.variants) || !Object.keys(raw.variants).length) errors.push("variants must be an object mapping a tool to a catalog variant slug");
|
|
95
|
+
else {
|
|
96
|
+
for (const [tool, slug] of Object.entries(raw.variants)) {
|
|
97
|
+
if (!MEDIA_SIDECAR_TOOLS.includes(tool) || (Array.isArray(tools) && !tools.includes(tool))) errors.push(`variants.${tool}: not one of the configured tools`);
|
|
98
|
+
if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) errors.push(`variants.${tool} must be a catalog variant slug: ${JSON.stringify(slug)}`);
|
|
99
|
+
}
|
|
100
|
+
variants = { ...raw.variants };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
90
103
|
const stageDir = relativePath(raw.stageDir ?? "src-tauri/binaries", "stageDir");
|
|
91
104
|
let bundleDir;
|
|
92
105
|
if (os === "macos") {
|
|
@@ -109,7 +122,7 @@ export function validateMediaSidecarConfig(raw, { platform, hostArch = hostCatal
|
|
|
109
122
|
else stage = raw.stage;
|
|
110
123
|
}
|
|
111
124
|
if (errors.length) throw invalid("MEDIA_SIDECAR_CONFIG_INVALID", `invalid mediaSidecar configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
|
|
112
|
-
return { os, arch, variant, tools: [...tools], stageDir, ...(bundleDir ? { bundleDir } : {}), notices, ...(catalog ? { catalog } : {}), ...(stage ? { stage } : {}) };
|
|
125
|
+
return { os, arch, variant, ...(variants ? { variants } : {}), tools: [...tools], stageDir, ...(bundleDir ? { bundleDir } : {}), notices, ...(catalog ? { catalog } : {}), ...(stage ? { stage } : {}) };
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
/** `sign.nestedCode` entries for the macOS bundle: the sidecar executables, hardened runtime, no entitlements. */
|
|
@@ -126,9 +139,10 @@ export function mediaSidecarNestedCode(config) {
|
|
|
126
139
|
export function selectSidecarRows(catalog, config) {
|
|
127
140
|
if (!isObject(catalog) || !Array.isArray(catalog.artifacts)) throw invalid("MEDIA_SIDECAR_CATALOG_INVALID", "catalog has no artifacts array");
|
|
128
141
|
return config.tools.map((tool) => {
|
|
129
|
-
const
|
|
142
|
+
const variant = config.variants?.[tool] ?? config.variant;
|
|
143
|
+
const matches = catalog.artifacts.filter((row) => row.tool === tool && (row.variant ?? "decode") === variant
|
|
130
144
|
&& String(row.os).toLowerCase() === config.os && String(row.arch).toLowerCase() === config.arch);
|
|
131
|
-
const where = `${tool} ${
|
|
145
|
+
const where = `${tool} ${variant} ${config.os}/${config.arch}`;
|
|
132
146
|
if (matches.length === 0) throw invalid("MEDIA_SIDECAR_NO_ROW", `the rightkit-ffmpeg catalog has no ${where} row`);
|
|
133
147
|
if (matches.length > 1) throw invalid("MEDIA_SIDECAR_AMBIGUOUS_ROW", `the rightkit-ffmpeg catalog has ${matches.length} ${where} rows`);
|
|
134
148
|
const [row] = matches;
|
|
@@ -152,6 +166,7 @@ export function planMediaSidecar({ config, catalog, crateDir, crateVersion, cata
|
|
|
152
166
|
artifactId: row.id,
|
|
153
167
|
version: row.version,
|
|
154
168
|
contentSha256: row.contentSha256,
|
|
169
|
+
...(config.variants ? { variant: row.variant ?? "decode" } : {}),
|
|
155
170
|
stagedPath: path.resolve(root, config.stageDir, stagedFileName(row.tool, config.os, config.arch)),
|
|
156
171
|
bundledName: bundledFileName(row.tool, config.os),
|
|
157
172
|
...(config.bundleDir ? { bundlePath: `${config.bundleDir}/${bundledFileName(row.tool, config.os)}` } : {}),
|
|
@@ -242,6 +257,7 @@ export function buildMediaSidecarReceipt(plan, preSign, signed, { notices = [],
|
|
|
242
257
|
tool: file.tool,
|
|
243
258
|
artifactId: file.artifactId,
|
|
244
259
|
version: file.version,
|
|
260
|
+
...(file.variant ? { variant: file.variant } : {}),
|
|
245
261
|
catalogContentSha256: file.contentSha256,
|
|
246
262
|
preSignSha256: file.preSignSha256,
|
|
247
263
|
signedSha256: after.sha256,
|