@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/development-signing.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { spawnSync } from "./background-process.mjs";
|
|
6
6
|
import { runMacCodesignCommands } from "./macos-signing-session.mjs";
|
|
7
|
+
import { bundleRelativePath, compileBundlePathMap, globToRegExp, isGlobPattern, lookupBundlePath, resolveBundleIdentifier, validateBundleRelativeKey, validateIdentifierTemplate, validateSigningIdentifier } from "./mac-bundle-signing.mjs";
|
|
7
8
|
|
|
8
9
|
export class MacosDevelopmentSigningError extends Error {
|
|
9
10
|
constructor(code, message, details = {}) {
|
|
@@ -100,7 +101,8 @@ export function resolveMacosDevelopmentSigning({ root, bundle, descriptor = {},
|
|
|
100
101
|
if (existsSync(file)) mac = { ...mac, ...JSON.parse(readFileSync(file, "utf8")).bundle?.macOS };
|
|
101
102
|
}
|
|
102
103
|
const sign = config.targets?.mac?.sign ?? {};
|
|
103
|
-
|
|
104
|
+
// `sign.entitlements` is the app-level file when a string; an object is the per-path map (readDevSigningSettings).
|
|
105
|
+
const entitlements = typeof sign.entitlements === "string" && sign.entitlements ? path.resolve(root, sign.entitlements) : mac.entitlements ? path.resolve(tauriRoot, mac.entitlements) : null;
|
|
104
106
|
if (entitlements && !existsSync(entitlements)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", `Configured entitlements file is missing: ${entitlements}`);
|
|
105
107
|
// CI-only ad hoc: no Keychain, no provisioning profile (a profile authorizes restricted entitlements that an ad hoc signature cannot carry).
|
|
106
108
|
if (ciAdhoc) return { identity: "-", certificateSha1: "-", teamIdentifier: null, entitlements, provisioningProfile: null, mode: CI_ADHOC_SIGNING, adhoc: true };
|
|
@@ -158,9 +160,128 @@ function macho(file) {
|
|
|
158
160
|
} finally { closeSync(fd); }
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
const invalidConfig = (message) => { throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", message); };
|
|
164
|
+
const ASSERT_KEYS = new Set(["path", "identifier", "teamIdentifier", "authority"]);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Declarative per-binary signing settings of a dev install. Everything is optional; with none declared the result is
|
|
168
|
+
* empty and signing is exactly what it was before these keys existed.
|
|
169
|
+
* install.identifiers / sign.identifiers { path-or-glob: "--identifier value" } ("." is the bundle)
|
|
170
|
+
* install.identifierTemplate / sign.identifierTemplate default identifier for remaining plain Mach-O files
|
|
171
|
+
* install.entitlements / sign.entitlements (object) { path-or-glob: plist path } (a string sign.entitlements is the app file)
|
|
172
|
+
* install.timestamp true adds --timestamp (a secure timestamp contacts Apple's server)
|
|
173
|
+
* install.assert [{ path-or-glob, identifier?, teamIdentifier?, authority? }]
|
|
174
|
+
* install.assertTeamSignedNested true: every Mach-O in the bundle carries the app's TeamIdentifier
|
|
175
|
+
* install.* entries win over sign.* entries (so one declaration in `sign` keeps dev and release identifiers identical).
|
|
176
|
+
* `root` resolves entitlement plist paths and checks they exist; without it only the shape is validated.
|
|
177
|
+
*/
|
|
178
|
+
export function readDevSigningSettings(descriptor = {}, config = {}, { root, exists = existsSync } = {}) {
|
|
179
|
+
const sign = config?.targets?.mac?.sign ?? {};
|
|
180
|
+
const identifier = (raw, label) => validateSigningIdentifier(raw, label, invalidConfig);
|
|
181
|
+
const identifiers = [...compileBundlePathMap(descriptor.identifiers, "install.identifiers", { reject: invalidConfig, value: identifier }),
|
|
182
|
+
...compileBundlePathMap(sign.identifiers, "sign.identifiers", { reject: invalidConfig, value: identifier })];
|
|
183
|
+
const templateSource = descriptor.identifierTemplate !== undefined ? ["install.identifierTemplate", descriptor.identifierTemplate] : sign.identifierTemplate !== undefined ? ["sign.identifierTemplate", sign.identifierTemplate] : null;
|
|
184
|
+
const identifierTemplate = templateSource ? validateIdentifierTemplate(templateSource[1], templateSource[0], invalidConfig) : undefined;
|
|
185
|
+
const plistPath = (raw, label) => {
|
|
186
|
+
if (typeof raw !== "string" || !raw.trim()) invalidConfig(`${label} must be an entitlements plist path`);
|
|
187
|
+
if (!root) return raw;
|
|
188
|
+
const file = path.resolve(root, raw);
|
|
189
|
+
if (!exists(file)) invalidConfig(`${label} entitlements file not found: ${file}`);
|
|
190
|
+
return file;
|
|
191
|
+
};
|
|
192
|
+
const signMap = typeof sign.entitlements === "object" && sign.entitlements !== null && !Array.isArray(sign.entitlements) ? sign.entitlements : undefined;
|
|
193
|
+
const entitlements = [...compileBundlePathMap(descriptor.entitlements, "install.entitlements", { reject: invalidConfig, value: plistPath }),
|
|
194
|
+
...compileBundlePathMap(signMap, "sign.entitlements", { reject: invalidConfig, value: plistPath })];
|
|
195
|
+
if (descriptor.timestamp !== undefined && typeof descriptor.timestamp !== "boolean") invalidConfig("install.timestamp must be true or false");
|
|
196
|
+
if (descriptor.assertTeamSignedNested !== undefined && typeof descriptor.assertTeamSignedNested !== "boolean") invalidConfig("install.assertTeamSignedNested must be true or false");
|
|
197
|
+
const rawAssertions = descriptor.assert ?? [];
|
|
198
|
+
if (!Array.isArray(rawAssertions)) invalidConfig("install.assert must be an array of { path, identifier?, teamIdentifier?, authority? }");
|
|
199
|
+
const assertions = rawAssertions.map((item, index) => {
|
|
200
|
+
const label = `install.assert[${index}]`;
|
|
201
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) invalidConfig(`${label} must be an object`);
|
|
202
|
+
for (const key of Object.keys(item)) if (!ASSERT_KEYS.has(key)) invalidConfig(`${label}.${key} is not a known key (path, identifier, teamIdentifier, authority)`);
|
|
203
|
+
validateBundleRelativeKey(item.path, `${label}.path`, invalidConfig);
|
|
204
|
+
const checks = ["identifier", "teamIdentifier", "authority"].filter((key) => item[key] !== undefined);
|
|
205
|
+
if (!checks.length) invalidConfig(`${label} needs at least one of identifier, teamIdentifier, authority`);
|
|
206
|
+
for (const key of checks) if (typeof item[key] !== "string" || !item[key].trim()) invalidConfig(`${label}.${key} must be a non-empty string`);
|
|
207
|
+
return { path: item.path, ...Object.fromEntries(checks.map((key) => [key, item[key]])) };
|
|
208
|
+
});
|
|
209
|
+
return { identifiers, identifierTemplate, entitlements, timestamp: descriptor.timestamp === true, assertions, assertTeamSignedNested: descriptor.assertTeamSignedNested === true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function listBundleFiles(bundle) {
|
|
213
|
+
const out = [];
|
|
214
|
+
const walk = (directory) => {
|
|
215
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
216
|
+
const file = path.join(directory, entry.name);
|
|
217
|
+
if (entry.isSymbolicLink()) continue;
|
|
218
|
+
if (entry.isDirectory()) walk(file);
|
|
219
|
+
else if (entry.isFile()) out.push(bundleRelativePath(bundle, file));
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
walk(bundle);
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Post-sign identity assertions, read from `codesign -dvv` (stdout and stderr: codesign reports on stderr). Fails closed with
|
|
228
|
+
* DEV_MAC_SIGNING_ASSERT_FAILED listing every mismatch. A glob that matches no file is a failure, so a typo cannot pass.
|
|
229
|
+
* `assertTeamSignedNested` additionally requires every Mach-O in the bundle (dylibs included) to be signed by
|
|
230
|
+
* `teamIdentifier`, not ad hoc. Used on the signed staging copy and again on the installed copy (a failure there rolls back).
|
|
231
|
+
* Injectable: run, listFiles(bundle) -> bundle-relative file paths, isMachO(file), join.
|
|
232
|
+
*/
|
|
233
|
+
export function assertSignedIdentities({ root, bundle, settings, teamIdentifier, identity, env = process.env, run = spawnSync, listFiles = listBundleFiles, isMachO = macho, join = path.join, label = "signed bundle" }) {
|
|
234
|
+
const { assertions = [], assertTeamSignedNested = false } = settings ?? {};
|
|
235
|
+
if (!assertions.length && !assertTeamSignedNested) return { checked: 0 };
|
|
236
|
+
const reports = new Map();
|
|
237
|
+
const report = (file) => {
|
|
238
|
+
if (!reports.has(file)) {
|
|
239
|
+
const result = run("codesign", ["-dvv", file], captureOptions(root, env));
|
|
240
|
+
if (result.error || result.status !== 0) {
|
|
241
|
+
throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_ASSERT_FAILED", `${label}: cannot inspect the signing identity of ${file}: ${result.error?.message || output(result) || `exit ${result.status ?? "unavailable"}`}`, { identity });
|
|
242
|
+
}
|
|
243
|
+
const text = output(result);
|
|
244
|
+
reports.set(file, { identifier: /^Identifier=(.+)$/m.exec(text)?.[1]?.trim(), team: /^TeamIdentifier=(.+)$/m.exec(text)?.[1]?.trim(), authorities: [...text.matchAll(/^Authority=(.+)$/gm)].map((match) => match[1].trim()), adhoc: /^Signature=adhoc$/m.test(text) });
|
|
245
|
+
}
|
|
246
|
+
return reports.get(file);
|
|
247
|
+
};
|
|
248
|
+
const absolute = (relative) => (relative === "." ? bundle : join(bundle, ...relative.split("/")));
|
|
249
|
+
let files;
|
|
250
|
+
const listed = () => (files ??= listFiles(bundle));
|
|
251
|
+
const problems = [];
|
|
252
|
+
let checked = 0;
|
|
253
|
+
for (const item of assertions) {
|
|
254
|
+
const targets = isGlobPattern(item.path) ? listed().filter((relative) => globToRegExp(item.path).test(relative)) : [item.path];
|
|
255
|
+
if (!targets.length) { problems.push(`${item.path}: matches no file in the bundle`); continue; }
|
|
256
|
+
for (const relative of targets) {
|
|
257
|
+
const actual = report(absolute(relative));
|
|
258
|
+
checked += 1;
|
|
259
|
+
if (item.identifier !== undefined && actual.identifier !== item.identifier) problems.push(`${relative}: Identifier=${actual.identifier ?? "missing"}, expected ${item.identifier}`);
|
|
260
|
+
if (item.teamIdentifier !== undefined && actual.team !== item.teamIdentifier) problems.push(`${relative}: TeamIdentifier=${actual.team ?? "missing"}, expected ${item.teamIdentifier}`);
|
|
261
|
+
if (item.authority !== undefined && !actual.authorities.includes(item.authority)) problems.push(`${relative}: Authority does not include ${JSON.stringify(item.authority)} (found ${actual.authorities.map((name) => JSON.stringify(name)).join(", ") || "none"})`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (assertTeamSignedNested) {
|
|
265
|
+
if (!teamIdentifier) problems.push("assertTeamSignedNested: the expected TeamIdentifier is unknown (ad hoc signing has none)");
|
|
266
|
+
else {
|
|
267
|
+
for (const relative of [".", ...listed().filter((entry) => isMachO(absolute(entry)))]) {
|
|
268
|
+
const actual = report(absolute(relative));
|
|
269
|
+
checked += 1;
|
|
270
|
+
if (actual.adhoc || actual.team !== teamIdentifier) problems.push(`${relative}: ${actual.adhoc ? "ad hoc signed" : `TeamIdentifier=${actual.team ?? "missing"}`}, expected TeamIdentifier=${teamIdentifier}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (problems.length) {
|
|
275
|
+
throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_ASSERT_FAILED", `${label}: signing identity assertions failed (${problems.length}): ${problems.slice(0, 20).join("; ")}${problems.length > 20 ? "; ..." : ""}. Refusing dev installation.`, { identity, problems });
|
|
276
|
+
}
|
|
277
|
+
return { checked };
|
|
278
|
+
}
|
|
279
|
+
|
|
161
280
|
export function signMacosDevelopmentBundle(options) {
|
|
162
281
|
const { root, bundle, env = process.env, run = spawnSync } = options;
|
|
163
282
|
const context = { root, env, run };
|
|
283
|
+
// Declared per-binary settings are validated before any Keychain or codesign call.
|
|
284
|
+
const settings = readDevSigningSettings(options.descriptor, options.config, { root });
|
|
164
285
|
const signing = resolveMacosDevelopmentSigning({ ...options, env, run });
|
|
165
286
|
const { identity, certificateSha1, teamIdentifier, entitlements, provisioningProfile } = signing;
|
|
166
287
|
const embedded = path.join(bundle, "Contents", "embedded.provisionprofile");
|
|
@@ -218,12 +339,20 @@ export function signMacosDevelopmentBundle(options) {
|
|
|
218
339
|
const commands = [];
|
|
219
340
|
for (const target of ordered) {
|
|
220
341
|
// Bind the exact profile-authorized certificate even when names collide in Keychain.
|
|
221
|
-
const
|
|
342
|
+
const relative = bundleRelativePath(bundle, target.file);
|
|
343
|
+
let identifier;
|
|
344
|
+
try {
|
|
345
|
+
identifier = resolveBundleIdentifier({ relative, identifiers: settings.identifiers, template: settings.identifierTemplate, bundleId: options.descriptor?.bundleId ?? appInfo.CFBundleIdentifier, plain: !target.info });
|
|
346
|
+
} catch (cause) { throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", cause.message, { cause }); }
|
|
347
|
+
// --timestamp only when declared (install.timestamp): it needs Apple's timestamp server, and dev builds otherwise sign offline.
|
|
348
|
+
const args = ["--force", "--options", "runtime", ...(settings.timestamp ? ["--timestamp"] : []), ...(identifier ? ["--identifier", identifier] : []), "--sign", certificateSha1];
|
|
222
349
|
const parent = target.file === bundle;
|
|
350
|
+
const mappedEntitlements = lookupBundlePath(settings.entitlements, relative || ".");
|
|
223
351
|
const profiledHelper = target.info && existsSync(path.join(target.file, "Contents/embedded.provisionprofile")) && target.info.CFBundleIdentifier === appInfo.CFBundleIdentifier;
|
|
224
352
|
if (profiledHelper && !parent && provisioningProfile) copyFileSync(embedded, path.join(target.file, "Contents/embedded.provisionprofile"));
|
|
225
353
|
const variant = !parent && entitlementVariants.find((item) => path.basename(target.file).replace(/\.(app|xpc|appex|bundle)$/, "").toLowerCase().endsWith(item.suffix));
|
|
226
|
-
if (
|
|
354
|
+
if (mappedEntitlements) args.push("--entitlements", entitlementFile(mappedEntitlements));
|
|
355
|
+
else if (variant) args.push("--entitlements", entitlementFile(variant.file));
|
|
227
356
|
else if (entitlements && (parent || profiledHelper)) args.push("--entitlements", entitlementFile(entitlements));
|
|
228
357
|
else {
|
|
229
358
|
const existing = run("codesign", ["--display", "--entitlements", ":-", target.file], captureOptions(root, env));
|
|
@@ -248,7 +377,9 @@ export function signMacosDevelopmentBundle(options) {
|
|
|
248
377
|
const requirement = output(checked("codesign", ["--display", "-r-", bundle], context, "DEV_MAC_SIGNATURE_INVALID", identity));
|
|
249
378
|
const designated = /^designated => (.+)$/m.exec(requirement)?.[1]?.trim();
|
|
250
379
|
if (!designated || /\bcdhash\b/.test(designated)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_UNSTABLE_REQUIREMENT", `Signing identity ${JSON.stringify(identity)}: designated requirement ${designated ? JSON.stringify(designated) : "is missing"} is not stable across rebuilds (cdhash-bound). Refusing dev installation.`, { identity, designatedRequirement: designated });
|
|
251
|
-
|
|
380
|
+
// Declared identity assertions (install.assert, install.assertTeamSignedNested) on the signed staging copy, before any install.
|
|
381
|
+
const asserted = assertSignedIdentities({ root, bundle, settings, teamIdentifier, identity, env, run, label: "signed staging copy", ...(options.listFiles ? { listFiles: options.listFiles } : {}), ...(options.isMachO ? { isMachO: options.isMachO } : {}) });
|
|
382
|
+
return { ...signing, provisioningProfile: provisioningProfile ? embedded : null, verified: "strict-deep", designatedRequirement: designated, signedTargets: ordered.length, ...(asserted.checked ? { assertedIdentities: asserted.checked } : {}) };
|
|
252
383
|
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
|
253
384
|
}
|
|
254
385
|
|
package/development.mjs
CHANGED
|
@@ -105,7 +105,7 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
105
105
|
// Android: cargo-ndk (Cargo dev profile) fills the generated jniLibs, then the Gradle debug build packages it.
|
|
106
106
|
if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, label: "native build" }); }
|
|
107
107
|
for (const command of target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
|
|
108
|
-
// Dev and QA binaries run from one stable staged path, copied in place (
|
|
108
|
+
// Dev and QA binaries run from one stable staged path, copied in place (no backup of the old copy), never from the
|
|
109
109
|
// broker's per-generation target folder or a hashed dev-targets folder.
|
|
110
110
|
const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
|
|
111
111
|
for (const file of staged) console.log(`right-release dev: staged ${file}`);
|
|
@@ -114,7 +114,7 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
114
114
|
// installTauriDevId is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
|
|
115
115
|
const built = resolveTauriArtifact(target.install, { root, platform, env: childEnv });
|
|
116
116
|
releaseLease = false;
|
|
117
|
-
return installTauriDevId({ appRoot: root, config: target.install, built, env: childEnv })
|
|
117
|
+
return installTauriDevId({ appRoot: root, config: target.install, releaseConfig: config, built, env: childEnv })
|
|
118
118
|
.then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), devid: outcome }))
|
|
119
119
|
.finally(() => lease.release());
|
|
120
120
|
}
|
|
@@ -140,9 +140,10 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
|
|
|
140
140
|
// was running, pass /R so the installer relaunches it as the user (RunAsUser, outside this process tree).
|
|
141
141
|
const running = isWindowsProcessRunning(path.basename(descriptor.expectedInstalledPath), { run });
|
|
142
142
|
if (running) console.log(`right-release dev: ${descriptor.productName} is running; the installer will close it and relaunch it`);
|
|
143
|
-
// The silent installer updates the fixed path in place.
|
|
143
|
+
// The silent installer updates the fixed path in place. NO backup of the old copy (Orthic-Labs/rightkit#348): a stale
|
|
144
|
+
// <exe>.previous left by an older RightKit is removed, nothing is copied, and a failed install is reinstalled from an older build.
|
|
144
145
|
const installedBefore = path.resolve(root, descriptor.expectedInstalledPath);
|
|
145
|
-
|
|
146
|
+
rmSync(previousPathOf(installedBefore), { recursive: true, force: true });
|
|
146
147
|
const result = run(app, windowsInstallerArguments({ running }), { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
|
|
147
148
|
if (result.status !== 0) throw new Error(`development installer failed (${result.status}): ${app}`);
|
|
148
149
|
const installed = path.resolve(root, descriptor.expectedInstalledPath);
|
|
@@ -156,7 +157,6 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
|
|
|
156
157
|
mkdirSync(path.dirname(destination), { recursive: true });
|
|
157
158
|
// Fixed staging name (not per-pid): the copy is signed and verified here, then renamed into place. Nothing runs from it.
|
|
158
159
|
const staging = stagingPathOf(destination);
|
|
159
|
-
const previous = previousPathOf(destination);
|
|
160
160
|
rmSync(staging, { recursive: true, force: true });
|
|
161
161
|
cpSync(app, staging, { recursive: true, verbatimSymlinks: true });
|
|
162
162
|
let signing;
|
|
@@ -167,7 +167,7 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
|
|
|
167
167
|
if (!signing.adhoc && existsSync(destination)) assertStableDesignatedRequirement({ root, installed: destination, expected: signing.designatedRequirement, env, run });
|
|
168
168
|
executableName = readBundleExecutable(staging, { root, env, run });
|
|
169
169
|
} catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
|
|
170
|
-
swapInstalledMacosBundle({ staging, destination,
|
|
170
|
+
swapInstalledMacosBundle({ staging, destination, executableName });
|
|
171
171
|
if (signing.provisioningProfile) signing.provisioningProfile = path.join(destination, "Contents", "embedded.provisionprofile");
|
|
172
172
|
if (signing.adhoc) console.log(`right-release dev: installed ${destination}; signing=ci-adhoc (ad hoc signed on a CI runner; NOT a release artifact); signature=adhoc; TeamIdentifier=none`);
|
|
173
173
|
else console.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; profile=${signing.provisioningProfile ?? "none"}; designated=${signing.designatedRequirement}`);
|
|
@@ -181,12 +181,13 @@ export class DevelopmentInstallError extends Error {
|
|
|
181
181
|
const NODE_FS = { existsSync, lstatSync, mkdirSync, copyFileSync, readFileSync, renameSync, rmSync, statSync };
|
|
182
182
|
|
|
183
183
|
/**
|
|
184
|
-
* Move the signed staging bundle into place
|
|
184
|
+
* Move the signed staging bundle into place IN PLACE with NO backup (Orthic-Labs/rightkit#348: the old app is removed,
|
|
185
|
+
* never kept as `<destination>.previous`; a stale `.previous` is removed too), and verify it: Info.plist exists and
|
|
185
186
|
* 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
|
|
186
|
-
* new install,
|
|
187
|
+
* new (absent or partial) install, does NOT restore the old app (reinstall from an older DMG), and throws a DevelopmentInstallError (code DEV_INSTALL_VERIFICATION_FAILED,
|
|
187
188
|
* DEV_INSTALL_EXECUTABLE_MISSING or DEV_INSTALL_EXECUTABLE_MISMATCH). `fs` is the file-system seam (tests inject it).
|
|
188
189
|
*/
|
|
189
|
-
export function swapInstalledMacosBundle({ staging, destination,
|
|
190
|
+
export function swapInstalledMacosBundle({ staging, destination, executableName, fs = NODE_FS }) {
|
|
190
191
|
if (typeof executableName !== "string" || !executableName) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", "swapInstalledMacosBundle requires the staged CFBundleExecutable name");
|
|
191
192
|
const executable = path.join("Contents", "MacOS", executableName);
|
|
192
193
|
const digest = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
@@ -196,9 +197,9 @@ export function swapInstalledMacosBundle({ staging, destination, previous = prev
|
|
|
196
197
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
197
198
|
throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `staged app has no main executable ${executable}: ${staging} (${error.message})`);
|
|
198
199
|
}
|
|
199
|
-
// The old app
|
|
200
|
+
// The old app is removed and the staged one renamed over it; no old copy survives, even on failure.
|
|
200
201
|
swapInPlace({
|
|
201
|
-
staging, destination, previous, fs,
|
|
202
|
+
staging, destination, previous: null, fs,
|
|
202
203
|
verify: () => {
|
|
203
204
|
if (!fs.existsSync(path.join(destination, "Contents", "Info.plist"))) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", `installed app verification failed: ${destination}`);
|
|
204
205
|
const installed = path.join(destination, executable);
|
package/dist-scan.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fails when a built web bundle (vite `dist/`) carries dev, mock or fixture code (replaces per-app
|
|
3
|
+
// checkReleaseBundle.mjs copies). Scans text files for forbidden marker strings and flags chunk file
|
|
4
|
+
// names that match a forbidden name pattern. Read-only: it never writes, builds or follows a symlink.
|
|
5
|
+
// Usage: right-release dist-scan --dist <dir> --forbid <string>... [--forbid-file <path>]
|
|
6
|
+
// [--forbid-name <regex>]... [--ext js,mjs,css,html,json,map]
|
|
7
|
+
// A --forbid-file is JSON (`["a","b"]` or `{ "forbid": [...], "forbidNames": [...] }`) or plain text with
|
|
8
|
+
// one marker per line (blank lines and `#` comments ignored).
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_EXTENSIONS = Object.freeze(["js", "mjs", "css", "html", "json", "map"]);
|
|
14
|
+
export const DEFAULT_FORBIDDEN_NAMES = Object.freeze([/^(devSource|mockDataSource|fixtures)[-.]/]);
|
|
15
|
+
|
|
16
|
+
export function parseForbidFile(text, name = "forbid file") {
|
|
17
|
+
const trimmed = text.trim();
|
|
18
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
19
|
+
let data;
|
|
20
|
+
try { data = JSON.parse(trimmed); } catch (error) { throw new Error(`${name}: invalid JSON (${error.message})`); }
|
|
21
|
+
const forbid = Array.isArray(data) ? data : data.forbid ?? [];
|
|
22
|
+
const forbidNames = Array.isArray(data) ? [] : data.forbidNames ?? [];
|
|
23
|
+
if (!Array.isArray(forbid) || !Array.isArray(forbidNames) || ![...forbid, ...forbidNames].every((s) => typeof s === "string" && s)) throw new Error(`${name}: entries must be non-empty strings`);
|
|
24
|
+
return { forbid, forbidNames };
|
|
25
|
+
}
|
|
26
|
+
return { forbid: trimmed.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#")), forbidNames: [] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Scan `dist`. Returns { dist, files, scanned, markers, offenders: [{file, reason}] }. */
|
|
30
|
+
export function scanDist(dist, { forbid = [], forbidNames = [], extensions = DEFAULT_EXTENSIONS } = {}) {
|
|
31
|
+
const root = resolve(dist);
|
|
32
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`No ${root}: build first.`);
|
|
33
|
+
const nameRes = [...DEFAULT_FORBIDDEN_NAMES, ...forbidNames.map((pattern) => {
|
|
34
|
+
try { return new RegExp(pattern); } catch (error) { throw new Error(`--forbid-name ${JSON.stringify(pattern)} is not a regular expression (${error.message})`); }
|
|
35
|
+
})];
|
|
36
|
+
const extRe = new RegExp(`\\.(${extensions.map((e) => e.replace(/[^\w]/g, "")).filter(Boolean).join("|")})$`);
|
|
37
|
+
const offenders = [];
|
|
38
|
+
let files = 0;
|
|
39
|
+
let scanned = 0;
|
|
40
|
+
const walk = (dir) => {
|
|
41
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
42
|
+
const path = join(dir, entry.name);
|
|
43
|
+
if (entry.isSymbolicLink()) continue;
|
|
44
|
+
if (entry.isDirectory()) { walk(path); continue; }
|
|
45
|
+
files += 1;
|
|
46
|
+
const rel = relative(root, path);
|
|
47
|
+
if (nameRes.some((re) => re.test(basename(path)))) offenders.push({ file: rel, reason: "forbidden chunk name" });
|
|
48
|
+
if (!extRe.test(path)) continue;
|
|
49
|
+
scanned += 1;
|
|
50
|
+
const text = readFileSync(path, "utf8");
|
|
51
|
+
for (const marker of forbid) if (text.includes(marker)) offenders.push({ file: rel, reason: `marker: ${marker}` });
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
walk(root);
|
|
55
|
+
return { dist: root, files, scanned, markers: forbid.length, offenders };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function distScanFailed(result) {
|
|
59
|
+
return result.offenders.length > 0 || result.files === 0 || result.markers === 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const USAGE = "usage: right-release dist-scan --dist <dir> --forbid <string>... [--forbid-file <path>] [--forbid-name <regex>]... [--ext js,css,...]";
|
|
63
|
+
|
|
64
|
+
export function parseArgs(argv) {
|
|
65
|
+
const out = { dist: null, forbid: [], forbidNames: [], extensions: undefined };
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const a = argv[i];
|
|
68
|
+
const value = () => { const v = argv[++i]; if (v === undefined || v.startsWith("--")) throw new Error(`${a} needs a value`); return v; };
|
|
69
|
+
if (a === "--dist") out.dist = value();
|
|
70
|
+
else if (a === "--forbid") out.forbid.push(value());
|
|
71
|
+
else if (a === "--forbid-name") out.forbidNames.push(value());
|
|
72
|
+
else if (a === "--ext") out.extensions = value().split(",").filter(Boolean);
|
|
73
|
+
else if (a === "--forbid-file") {
|
|
74
|
+
const file = value();
|
|
75
|
+
const parsed = parseForbidFile(readFileSync(file, "utf8"), file);
|
|
76
|
+
out.forbid.push(...parsed.forbid);
|
|
77
|
+
out.forbidNames.push(...parsed.forbidNames);
|
|
78
|
+
} else throw new Error(`unknown argument ${a}`);
|
|
79
|
+
}
|
|
80
|
+
if (!out.dist) throw new Error("--dist is required");
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function main(argv) {
|
|
85
|
+
let result;
|
|
86
|
+
try {
|
|
87
|
+
const args = parseArgs(argv);
|
|
88
|
+
if (args.forbid.length === 0) throw new Error("no --forbid markers given");
|
|
89
|
+
result = scanDist(args.dist, args);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error(`right-release dist-scan: ${error.message}\n${USAGE}`);
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
if (distScanFailed(result)) {
|
|
95
|
+
console.error(`Release bundle contains forbidden code:\n${result.offenders.map((o) => `${o.file}: ${o.reason}`).join("\n") || `no files in ${result.dist}`}`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
console.log(`Release bundle clean: ${result.markers} markers absent from ${result.dist} (${result.scanned} files scanned)`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) process.exit(main(process.argv.slice(2)));
|
package/dmg-build.mjs
CHANGED
|
@@ -7,6 +7,10 @@ import { readDmgSpec } from "./dmg-spec.mjs";
|
|
|
7
7
|
import { detachWithRetrySync } from "./hdiutil-detach.mjs";
|
|
8
8
|
import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
|
|
9
9
|
import { assertNoQaControl } from "./qa-control-scan.mjs";
|
|
10
|
+
import { signMacosEntries, validateCodesignIdentifier } from "./mac-sign-core.mjs";
|
|
11
|
+
import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
|
|
12
|
+
import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
|
|
13
|
+
import { runMacCodesignCommands } from "./macos-signing-session.mjs";
|
|
10
14
|
|
|
11
15
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
12
16
|
const pair = (value, label, positive = false) => {
|
|
@@ -25,12 +29,44 @@ export function resolveDmgOptions(input = {}) {
|
|
|
25
29
|
return spec ? { ...readDmgSpec(spec, { app: explicit.app }), ...definedOnly(explicit) } : explicit;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Developer ID sign a finished disk image (#646): secure timestamp, no hardened runtime (a disk image has none), an
|
|
34
|
+
* optional stable `--identifier`, strict verification, and an optional signing receipt (the sign-macos.mjs shape) so a
|
|
35
|
+
* later notarize step can bind to it. It never notarizes or uploads. Developer ID only: no ad hoc or Apple Development fallback.
|
|
36
|
+
*/
|
|
37
|
+
export function signBuiltDmg({ file, identifier, receipt }) {
|
|
38
|
+
const identity = resolveMacosDeveloperIdIdentity();
|
|
39
|
+
const files = signMacosEntries([{ file, hardenedRuntime: false, options: [], ...(identifier ? { identifier } : {}) }], {
|
|
40
|
+
identity,
|
|
41
|
+
signOne: (codesignArgs) => runMacCodesignCommands([codesignArgs])[0],
|
|
42
|
+
inspect: (tool, args) => {
|
|
43
|
+
const result = spawnSync(tool, args, { encoding: "utf8" });
|
|
44
|
+
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
45
|
+
if (result.status !== 0) throw new Error(`${tool} ${args.join(" ")} failed: ${output.trim()}`);
|
|
46
|
+
return output;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
if (receipt) {
|
|
50
|
+
mkdirSync(path.dirname(receipt), { recursive: true });
|
|
51
|
+
writeFileSync(receipt, `${JSON.stringify({ schema: 1, signingContract: MACOS_PORTABLE_SIGNING_CONTRACT, identity, files }, null, 2)}\n`);
|
|
52
|
+
}
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the branded DMG; with `sign: true` (CLI `--sign`) it is then Developer ID signed (`identifier`, `signReceipt`
|
|
58
|
+
* optional) and a signing failure removes the unsigned image so nothing downstream can pick it up. `signDmg` is injectable.
|
|
59
|
+
*/
|
|
28
60
|
export function buildDmg(input = {},
|
|
29
61
|
{ platform = process.platform, run = spawnSync, makeAlias = backgroundAlias,
|
|
30
|
-
log = (event) => console.error(JSON.stringify(event)), sleep } = {}) {
|
|
62
|
+
log = (event) => console.error(JSON.stringify(event)), sleep, signDmg = signBuiltDmg } = {}) {
|
|
31
63
|
if (platform !== "darwin") throw fail("DMG_NATIVE_HOST_REQUIRED", "DMG creation requires macOS");
|
|
32
64
|
let { app, out, volname, background, iconSize = 128, appPosition = [158, 231],
|
|
33
|
-
applicationsPosition = [492, 231], window = [660, 400], windowPosition = [100, 100], volumeIcon
|
|
65
|
+
applicationsPosition = [492, 231], window = [660, 400], windowPosition = [100, 100], volumeIcon,
|
|
66
|
+
sign = false, identifier, signReceipt } = resolveDmgOptions(input);
|
|
67
|
+
if (identifier !== undefined && !sign) throw fail("DMG_INVALID_OPTIONS", "--identifier needs --sign");
|
|
68
|
+
if (signReceipt !== undefined && !sign) throw fail("DMG_INVALID_OPTIONS", "--receipt needs --sign");
|
|
69
|
+
if (identifier !== undefined) { try { validateCodesignIdentifier(identifier, "--identifier"); } catch (error) { throw fail("DMG_INVALID_OPTIONS", error.message); } }
|
|
34
70
|
if (!app || !out || !volname) throw fail("DMG_INVALID_OPTIONS", "--app, --out & --volname are required");
|
|
35
71
|
app = path.resolve(app); out = path.resolve(out);
|
|
36
72
|
if (!app.endsWith(".app") || !statSync(app).isDirectory() || !existsSync(path.join(app, "Contents", "Info.plist"))) {
|
|
@@ -103,8 +139,18 @@ export function buildDmg(input = {},
|
|
|
103
139
|
command("/usr/bin/hdiutil", ["convert", rw, "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", compressed]);
|
|
104
140
|
command("/usr/bin/hdiutil", ["verify", compressed]);
|
|
105
141
|
renameSync(compressed, out);
|
|
106
|
-
|
|
107
|
-
|
|
142
|
+
let signed;
|
|
143
|
+
if (sign) {
|
|
144
|
+
// Developer ID sign the finished image. A failure removes the unsigned image: nothing downstream may pick it up.
|
|
145
|
+
try { signed = signDmg({ file: out, ...(identifier ? { identifier } : {}), ...(signReceipt ? { receipt: path.resolve(signReceipt) } : {}) }); }
|
|
146
|
+
catch (error) {
|
|
147
|
+
rmSync(out, { force: true });
|
|
148
|
+
throw fail("DMG_SIGN_FAILED", `signing ${out} failed (the unsigned image was removed): ${error.message}`);
|
|
149
|
+
}
|
|
150
|
+
log({ event: "dmg-signed", out, ...(identifier ? { identifier } : {}) });
|
|
151
|
+
}
|
|
152
|
+
log({ event: "dmg-build-completed", out, format: "UDZO", signed: Boolean(sign) });
|
|
153
|
+
return { out, format: "UDZO", ...(sign ? { signed: true, signing: signed } : {}) };
|
|
108
154
|
} catch (error) {
|
|
109
155
|
log({ event: "dmg-build-failed", out, code: error.code ?? "DMG_BUILD_FAILED", message: error.message });
|
|
110
156
|
throw error;
|
|
@@ -122,8 +168,11 @@ export function parseDmgArguments(args) {
|
|
|
122
168
|
if (args[0] !== "build") throw fail("DMG_INVALID_OPTIONS", "expected dmg build");
|
|
123
169
|
const flags = new Map([["--app", "app"], ["--out", "out"], ["--volname", "volname"], ["--background", "background"],
|
|
124
170
|
["--icon-size", "iconSize"], ["--app-position", "appPosition"], ["--applications-position", "applicationsPosition"], ["--window", "window"], ["--window-position", "windowPosition"], ["--volume-icon", "volumeIcon"], ["--spec", "spec"]]);
|
|
171
|
+
flags.set("--identifier", "identifier"); flags.set("--receipt", "signReceipt");
|
|
125
172
|
const options = {};
|
|
126
173
|
for (let i = 1; i < args.length; i += 2) {
|
|
174
|
+
// `--sign` is the one value-less flag: Developer ID sign the finished image (#646).
|
|
175
|
+
if (args[i] === "--sign") { if (options.sign !== undefined) throw fail("DMG_INVALID_OPTIONS", "invalid option: --sign"); options.sign = true; i -= 1; continue; }
|
|
127
176
|
const key = flags.get(args[i]);
|
|
128
177
|
if (!key || options[key] !== undefined || !args[i + 1] || args[i + 1].startsWith("--")) throw fail("DMG_INVALID_OPTIONS", `invalid option: ${args[i]}`);
|
|
129
178
|
options[key] = args[i + 1];
|
package/icon-pack.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// App icon packers (bogusyogi/rightkit#675): assemble a Windows ICO or a macOS ICNS from per-size PNG frames, so
|
|
3
|
+
// hand-tuned small frames (16/24/32/48) are kept exactly as drawn instead of being down-scaled from one master.
|
|
4
|
+
// right-release icons ico --out <file.ico> --frame <PNG>... [--dir <folder of <size>.png>]
|
|
5
|
+
// right-release icons icns --out <file.icns> --frame <PNG>... [--dir <folder of <size>.png>]
|
|
6
|
+
// Every frame must be a square PNG; its size comes from its IHDR, never from the file name. ICO frames are stored
|
|
7
|
+
// as PNG (Vista+), sizes 1..256. ICNS accepts 16, 32, 64, 128, 256, 512 and 1024. Pure Buffer work, any host.
|
|
8
|
+
// Rendering a master image to PNG frames and DMG background art stay with the app's design source
|
|
9
|
+
// (dmg background: generate-dmg-background.py).
|
|
10
|
+
import { readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
14
|
+
export const ICNS_TYPES = Object.freeze({ 16: "icp4", 32: "icp5", 64: "icp6", 128: "ic07", 256: "ic08", 512: "ic09", 1024: "ic10" });
|
|
15
|
+
function fail(message, code = "ICON_INVALID") { throw Object.assign(new Error(message), { code }); }
|
|
16
|
+
|
|
17
|
+
/** Read the pixel size of a square PNG from its IHDR. */
|
|
18
|
+
export function pngSize(bytes, label = "frame") {
|
|
19
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
20
|
+
if (buf.length < 33 || !buf.subarray(0, 8).equals(PNG_MAGIC) || buf.toString("latin1", 12, 16) !== "IHDR") fail(`${label} is not a PNG`);
|
|
21
|
+
const width = buf.readUInt32BE(16), height = buf.readUInt32BE(20);
|
|
22
|
+
if (width !== height) fail(`${label} must be square, got ${width}x${height}`);
|
|
23
|
+
// Walk the chunk list: IHDR first (13 bytes), at least one IDAT, IEND last with nothing after it.
|
|
24
|
+
if (buf.readUInt32BE(8) !== 13) fail(`${label} has a malformed IHDR chunk`);
|
|
25
|
+
let at = 8, idat = false, iend = false, first = true;
|
|
26
|
+
while (at < buf.length) {
|
|
27
|
+
if (at + 12 > buf.length) fail(`${label} is a truncated PNG`);
|
|
28
|
+
const length = buf.readUInt32BE(at), type = buf.toString("latin1", at + 4, at + 8);
|
|
29
|
+
if (at + 12 + length > buf.length) fail(`${label} is a truncated PNG`);
|
|
30
|
+
if (iend) fail(`${label} has data after IEND`);
|
|
31
|
+
if (first && type !== "IHDR") fail(`${label} is not a PNG`);
|
|
32
|
+
first = false;
|
|
33
|
+
if (type === "IDAT") idat = true;
|
|
34
|
+
if (type === "IEND") iend = true;
|
|
35
|
+
at += 12 + length;
|
|
36
|
+
}
|
|
37
|
+
if (!idat) fail(`${label} has no image data (IDAT)`);
|
|
38
|
+
if (!iend) fail(`${label} is missing IEND`);
|
|
39
|
+
const depth = buf[24], colorType = buf[25];
|
|
40
|
+
if (depth !== 8 || colorType !== 6) fail(`${label} must be 8-bit RGBA (color type 6, bit depth 8), got bit depth ${depth}, color type ${colorType}`);
|
|
41
|
+
return width;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeFrames(frames, { min = 1, max = 1024, only = null } = {}) {
|
|
45
|
+
if (!Array.isArray(frames) || frames.length === 0) fail("at least one PNG frame is required", "ICON_USAGE");
|
|
46
|
+
const bySize = new Map();
|
|
47
|
+
for (const frame of frames) {
|
|
48
|
+
const bytes = Buffer.isBuffer(frame) ? frame : Buffer.from(frame);
|
|
49
|
+
const size = pngSize(bytes);
|
|
50
|
+
if (size < min || size > max || (only && !only.includes(size))) fail(`unsupported frame size ${size}${only ? ` (allowed: ${only.join(", ")})` : ` (allowed ${min}..${max})`}`);
|
|
51
|
+
if (bySize.has(size)) fail(`duplicate ${size}x${size} frame`);
|
|
52
|
+
bySize.set(size, bytes);
|
|
53
|
+
}
|
|
54
|
+
return [...bySize.entries()].sort((a, b) => a[0] - b[0]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {Buffer[]} frames PNG buffers, one per size (1..256). @returns {Buffer} ICO file. */
|
|
58
|
+
export function packIco(frames) {
|
|
59
|
+
const sorted = normalizeFrames(frames, { max: 256 });
|
|
60
|
+
const header = Buffer.alloc(6 + 16 * sorted.length);
|
|
61
|
+
header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(sorted.length, 4);
|
|
62
|
+
let offset = header.length;
|
|
63
|
+
sorted.forEach(([size, png], i) => {
|
|
64
|
+
const at = 6 + i * 16;
|
|
65
|
+
header[at] = size === 256 ? 0 : size; header[at + 1] = size === 256 ? 0 : size;
|
|
66
|
+
header.writeUInt16LE(1, at + 4); header.writeUInt16LE(32, at + 6);
|
|
67
|
+
header.writeUInt32LE(png.length, at + 8); header.writeUInt32LE(offset, at + 12);
|
|
68
|
+
offset += png.length;
|
|
69
|
+
});
|
|
70
|
+
return Buffer.concat([header, ...sorted.map(([, png]) => png)]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {Buffer[]} frames PNG buffers (16, 32, 64, 128, 256, 512, 1024). @returns {Buffer} ICNS file. */
|
|
74
|
+
export function packIcns(frames) {
|
|
75
|
+
const sorted = normalizeFrames(frames, { only: Object.keys(ICNS_TYPES).map(Number) });
|
|
76
|
+
const chunks = sorted.map(([size, png]) => {
|
|
77
|
+
const head = Buffer.alloc(8);
|
|
78
|
+
head.write(ICNS_TYPES[size], 0, "latin1"); head.writeUInt32BE(png.length + 8, 4);
|
|
79
|
+
return Buffer.concat([head, png]);
|
|
80
|
+
});
|
|
81
|
+
const total = 8 + chunks.reduce((n, c) => n + c.length, 0);
|
|
82
|
+
const head = Buffer.alloc(8);
|
|
83
|
+
head.write("icns", 0, "latin1"); head.writeUInt32BE(total, 4);
|
|
84
|
+
return Buffer.concat([head, ...chunks]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseArgs(argv) {
|
|
88
|
+
const [kind, ...rest] = argv;
|
|
89
|
+
const opts = { kind, out: null, frames: [], dirs: [] };
|
|
90
|
+
for (let i = 0; i < rest.length; i++) {
|
|
91
|
+
const arg = rest[i], value = rest[i + 1];
|
|
92
|
+
if (!["--out", "--frame", "--dir"].includes(arg)) fail(`unknown icons argument: ${arg}`, "ICON_USAGE");
|
|
93
|
+
if (value === undefined || value.startsWith("--")) fail(`${arg} needs a value`, "ICON_USAGE");
|
|
94
|
+
i += 1;
|
|
95
|
+
if (arg === "--out") opts.out = value; else if (arg === "--frame") opts.frames.push(value); else opts.dirs.push(value);
|
|
96
|
+
}
|
|
97
|
+
if (!["ico", "icns"].includes(kind) || !opts.out) fail("usage: right-release icons ico|icns --out <file> --frame <PNG>... [--dir <folder>]", "ICON_USAGE");
|
|
98
|
+
for (const dir of opts.dirs) for (const name of readdirSync(dir).filter((n) => n.toLowerCase().endsWith(".png")).sort()) opts.frames.push(path.join(dir, name));
|
|
99
|
+
return opts;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function main(argv = process.argv.slice(2), out = console) {
|
|
103
|
+
try {
|
|
104
|
+
const opts = parseArgs(argv);
|
|
105
|
+
const frames = opts.frames.map((file) => readFileSync(file));
|
|
106
|
+
const bytes = opts.kind === "ico" ? packIco(frames) : packIcns(frames);
|
|
107
|
+
mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true });
|
|
108
|
+
writeFileSync(opts.out, bytes);
|
|
109
|
+
out.log(JSON.stringify({ out: opts.out, kind: opts.kind, frames: opts.frames.length, bytes: bytes.length }));
|
|
110
|
+
return 0;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
out.error(error.message);
|
|
113
|
+
return error.code === "ICON_USAGE" ? 2 : 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) process.exit(main());
|