返回 DeepSeek-Reasonix
lib.mjs
根目录 / desktop / packaging / lib.mjs
1 import { closeSync, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs";
2 import { basename, dirname, join, relative, resolve } from "node:path";
3 import { spawnSync } from "node:child_process";
4 import { inflateRawSync } from "node:zlib";
5
6 // These package scripts are Node entry points. Starting Node directly keeps
7 // paths and arguments out of cmd.exe quoting, including trailing backslashes,
8 // embedded quotes and shell metacharacters in checkout paths.
9 export function runBuildScript(directory, script, args = [], env = {}) {
10 const result = spawnSync(process.execPath, [join(directory, "scripts", script), ...args], {
11 cwd: directory,
12 env: { ...process.env, ...env },
13 stdio: "inherit",
14 shell: false,
15 });
16 if (result.error) throw result.error;
17 if (result.status !== 0) throw new Error(`${script} exited with ${result.status ?? result.signal}`);
18 }
19
20 export const PRODUCT = Object.freeze({
21 name: "Reasonix",
22 executable: "Reasonix",
23 // The Wails-era CFBundleIdentifier (com.wails.<wails.json name>). LaunchServices,
24 // saved-state and the macOS update swap key off it, so it survives the shell change.
25 bundleId: "com.wails.reasonix-desktop",
26 serviceExecutable: "reasonix-desktop",
27 cliExecutable: "reasonix",
28 windowsCliExecutable: "reasonix-cli",
29 category: "public.app-category.developer-tools",
30 });
31
32 const TARGET_TABLE = {
33 "darwin/arm64": { os: "darwin", arch: "arm64", packagerPlatform: "darwin", packagerArch: "arm64" },
34 "darwin/amd64": { os: "darwin", arch: "amd64", packagerPlatform: "darwin", packagerArch: "x64" },
35 "darwin/universal": { os: "darwin", arch: "universal", packagerPlatform: "darwin", packagerArch: "universal" },
36 "windows/amd64": { os: "windows", arch: "amd64", packagerPlatform: "win32", packagerArch: "x64" },
37 "windows/arm64": { os: "windows", arch: "arm64", packagerPlatform: "win32", packagerArch: "arm64" },
38 "linux/amd64": { os: "linux", arch: "amd64", packagerPlatform: "linux", packagerArch: "x64" },
39 "linux/arm64": { os: "linux", arch: "arm64", packagerPlatform: "linux", packagerArch: "arm64" },
40 };
41
42 export function parseTarget(spec) {
43 const target = TARGET_TABLE[spec];
44 if (!target) throw new Error(`unsupported target ${JSON.stringify(spec)}; expected one of ${Object.keys(TARGET_TABLE).join(", ")}`);
45 return { ...target, spec, key: `${target.os}-${target.arch}` };
46 }
47
48 const TAG = /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$/;
49
50 export function versionTag(tag) {
51 if (!TAG.test(tag)) throw new Error(`version must look like v1.2.3 or v1.2.3-rc.1, got ${JSON.stringify(tag)}`);
52 return tag;
53 }
54
55 export function releaseVersions(tag) {
56 const canonical = versionTag(tag);
57 const display = canonical.slice(1);
58 return Object.freeze({ canonical, display, resource: display.split("-")[0] });
59 }
60
61 // Windows version resources, CFBundleVersion and the NSIS VIProductVersion only
62 // accept X.Y.Z; the full tag identifies the build through build.json and the
63 // Go -X main.version ldflag. Packager also writes appVersion to package.json.
64 export function numericVersion(tag) {
65 return releaseVersions(tag).resource;
66 }
67
68 export function displayVersion(tag) {
69 return releaseVersions(tag).display;
70 }
71
72 // The product identity lived in wails.json while the Wails shell was the build
73 // entry point; with the shell retired it is a constant here.
74 export function readProductIdentity() {
75 return {
76 projectName: PRODUCT.serviceExecutable,
77 companyName: "Reasonix",
78 productName: PRODUCT.name,
79 copyright: "Copyright © 2026 Reasonix Contributors",
80 };
81 }
82
83 export function shellIgnore(path) {
84 if (path === "" || path === "/package.json" || path === "/dist") return false;
85 if (path.startsWith("/dist/")) return path.endsWith(".map");
86 return true;
87 }
88
89 export function sanitizeShellPackageJson(pkg, { version, productName }) {
90 const keep = ["name", "description", "main", "type"];
91 const out = {};
92 for (const key of keep) if (key in pkg) out[key] = pkg[key];
93 out.productName = productName;
94 out.version = numericVersion(version);
95 return out;
96 }
97
98 export function buildInfo({ version, channel, commit, electronVersion, target, buildTime }) {
99 return {
100 schemaVersion: 1,
101 version: versionTag(version),
102 channel,
103 commit,
104 buildTime,
105 electron: electronVersion,
106 platform: `${target.os}/${target.arch}`,
107 };
108 }
109
110 export function packagerOptions({ target, version, identity, root, electronVersion, extraResources, icon }) {
111 const numeric = numericVersion(version);
112 const options = {
113 dir: join(root, "electron"),
114 out: join(root, "build", "electron", ".packager"),
115 name: PRODUCT.name,
116 executableName: PRODUCT.executable,
117 platform: target.packagerPlatform,
118 arch: target.packagerArch,
119 electronVersion,
120 appBundleId: PRODUCT.bundleId,
121 appVersion: numeric,
122 buildVersion: numeric,
123 appCopyright: identity.copyright,
124 appCategoryType: PRODUCT.category,
125 asar: true,
126 prune: true,
127 overwrite: true,
128 junk: true,
129 darwinDarkModeSupport: true,
130 extraResource: extraResources,
131 ignore: shellIgnore,
132 };
133 if (icon) options.icon = icon;
134 if (target.packagerPlatform === "win32") {
135 options.win32metadata = {
136 CompanyName: identity.companyName,
137 FileDescription: identity.productName,
138 ProductName: identity.productName,
139 InternalName: PRODUCT.executable,
140 OriginalFilename: `${PRODUCT.executable}.exe`,
141 };
142 }
143 return options;
144 }
145
146 export function nsisProjectDefines(identity, version) {
147 const versions = releaseVersions(version);
148 const lines = [
149 "; Generated by desktop/packaging/package.mjs - do not edit or commit.",
150 `!define INFO_PROJECTNAME "${identity.projectName}"`,
151 `!define INFO_COMPANYNAME "${identity.companyName}"`,
152 `!define INFO_PRODUCTNAME "${identity.productName}"`,
153 `!define INFO_PRODUCTVERSION "${versions.resource}"`,
154 `!define REASONIX_DISPLAY_VERSION "${versions.display}"`,
155 `!define INFO_COPYRIGHT "${identity.copyright}"`,
156 `!define REASONIX_VERSION_TAG "${versions.canonical}"`,
157 ];
158 // makensis only decodes an include as UTF-8 when it carries a BOM; the copyright sign needs it.
159 return "" + lines.join("\r\n") + "\r\n";
160 }
161
162 export function normalizeEntry(name) {
163 let out = name.replace(/\\/g, "/");
164 while (out.startsWith("./")) out = out.slice(2);
165 return out;
166 }
167
168 export function walkFiles(dir, base = dir) {
169 const out = [];
170 for (const entry of readdirSync(dir, { withFileTypes: true })) {
171 const path = join(dir, entry.name);
172 if (entry.isDirectory()) out.push(...walkFiles(path, base));
173 else out.push(normalizeEntry(relative(base, path)));
174 }
175 return out.sort();
176 }
177
178 const PE_SUFFIX = /\.(exe|dll)$/i;
179
180 export function signingFileList(entries) {
181 return [...new Set(entries.map(normalizeEntry).filter((name) => PE_SUFFIX.test(name)))].sort();
182 }
183
184 export function parseSigningFileList(text) {
185 return text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#"));
186 }
187
188 export const WINDOWS_FLAT_PAYLOAD = Object.freeze([
189 "reasonix-desktop.exe",
190 "reasonix-guard.exe",
191 "reasonix-launcher.exe",
192 "reasonix-update-helper.exe",
193 "reasonix-cli.exe",
194 "reasonix-uninstall.exe",
195 ]);
196
197 const APP_RESOURCES = ["resources/app.asar", "resources/app/index.html", "resources/build.json", "resources/icons/appicon.png"];
198
199 function darwinBundleMembers() {
200 const helper = (kind) => `Contents/Frameworks/${PRODUCT.name} Helper (${kind}).app/Contents/MacOS/${PRODUCT.name} Helper (${kind})`;
201 return [
202 "Contents/Info.plist",
203 `Contents/MacOS/${PRODUCT.executable}`,
204 `Contents/MacOS/${PRODUCT.serviceExecutable}`,
205 `Contents/Resources/service/${PRODUCT.serviceExecutable}`,
206 // The CLI sidecar lives in Resources/service/, never Contents/MacOS/: on
207 // case-insensitive APFS "reasonix" there collides with the Electron main
208 // executable "Reasonix" and cp would clobber it.
209 `Contents/Resources/service/${PRODUCT.cliExecutable}`,
210 "Contents/Resources/app.asar",
211 "Contents/Resources/app/index.html",
212 "Contents/Resources/build.json",
213 "Contents/Resources/icons/appicon.png",
214 "Contents/Frameworks/Electron Framework.framework/Electron Framework",
215 helper("Renderer"),
216 helper("GPU"),
217 ];
218 }
219
220 const VERSION_DIR = "versions/v[^/]+";
221 const PACKAGING_JUNK = /(^|\/)(?:[^/]+\.map|__tests__|testdata|\.cache|coverage|npm-debug\.log|pnpm-debug\.log|yarn-error\.log)(?:$|\/)/;
222
223 const MEMBERS = {
224 "darwin-app-dir": { required: darwinBundleMembers(), forbidden: ["Contents/MacOS/reasonix-guard"] },
225 "darwin-zip": {
226 required: darwinBundleMembers().map((name) => `${PRODUCT.name}.app/${name}`),
227 forbidden: [`${PRODUCT.name}.app/Contents/MacOS/reasonix-guard`],
228 },
229 "windows-app-dir": {
230 required: [`${PRODUCT.executable}.exe`, "ffmpeg.dll", "libEGL.dll", "libGLESv2.dll", "resources.pak", "icudtl.dat", "locales/en-US.pak", ...APP_RESOURCES],
231 forbidden: [],
232 },
233 "windows-portable-zip": {
234 required: [
235 `${PRODUCT.executable}.exe`,
236 `${PRODUCT.windowsCliExecutable}.exe`,
237 "current.json",
238 new RegExp(`^${VERSION_DIR}/reasonix-desktop\\.exe$`),
239 new RegExp(`^${VERSION_DIR}/reasonix-update-helper\\.exe$`),
240 new RegExp(`^${VERSION_DIR}/reasonix-cli\\.exe$`),
241 new RegExp(`^${VERSION_DIR}/app/${PRODUCT.executable}\\.exe$`),
242 new RegExp(`^${VERSION_DIR}/app/resources/bin/reasonix-cli-launcher\\.exe$`),
243 new RegExp(`^${VERSION_DIR}/app/resources/app\\.asar$`),
244 new RegExp(`^${VERSION_DIR}/app/resources/app/index\\.html$`),
245 new RegExp(`^${VERSION_DIR}/app/resources/build\\.json$`),
246 ],
247 forbidden: ["reasonix-guard.exe", "reasonix-desktop.exe"],
248 },
249 "linux-app-dir": {
250 required: [PRODUCT.executable, "chrome-sandbox", "chrome_crashpad_handler", "libffmpeg.so", "resources.pak", "locales/en-US.pak", ...APP_RESOURCES],
251 forbidden: [],
252 },
253 "linux-tar": {
254 required: ["reasonix-desktop", "reasonix-launcher", "reasonix-guard", "reasonix", `app/${PRODUCT.executable}`, "app/chrome-sandbox", ...APP_RESOURCES.map((name) => `app/${name}`)],
255 forbidden: [],
256 },
257 "linux-deb": {
258 required: [
259 "usr/bin/reasonix-desktop",
260 "usr/bin/reasonix-launcher",
261 "usr/bin/reasonix",
262 "usr/lib/reasonix/reasonix-update-helper",
263 `usr/lib/reasonix/app/${PRODUCT.executable}`,
264 "usr/lib/reasonix/app/chrome-sandbox",
265 ...APP_RESOURCES.map((name) => `usr/lib/reasonix/app/${name}`),
266 "usr/share/polkit-1/actions/io.reasonix.desktop.update.policy",
267 "usr/share/applications/reasonix.desktop",
268 ],
269 forbidden: ["usr/bin/reasonix-guard"],
270 },
271 };
272
273 export const ARTIFACT_KINDS = Object.freeze(Object.keys(MEMBERS));
274
275 export const WINDOWS_PORTABLE_LAYOUTS = Object.freeze(["canonical", "legacy-dual"]);
276
277 function memberSpec(kind, portableLayout) {
278 if (!WINDOWS_PORTABLE_LAYOUTS.includes(portableLayout)) throw new Error(`unknown Windows portable layout ${JSON.stringify(portableLayout)}`);
279 const spec = MEMBERS[kind];
280 if (!spec) throw new Error(`unknown artifact kind ${JSON.stringify(kind)}`);
281 if (kind !== "windows-portable-zip") return spec;
282 return portableLayout === "legacy-dual"
283 ? { required: [...spec.required, "reasonix-launcher.exe"], forbidden: spec.forbidden }
284 : { required: spec.required, forbidden: [...spec.forbidden, "reasonix-launcher.exe"] };
285 }
286
287 export function requiredMembers(kind, portableLayout = "canonical") {
288 return memberSpec(kind, portableLayout).required;
289 }
290
291 export function checkMembers(entries, kind, portableLayout = "canonical") {
292 const spec = memberSpec(kind, portableLayout);
293 const names = new Set(entries.map(normalizeEntry).filter((name) => name !== "" && !name.endsWith("/")));
294 const matches = (rule) => (rule instanceof RegExp ? [...names].some((name) => rule.test(name)) : names.has(rule));
295 const forbidden = [...spec.forbidden, PACKAGING_JUNK].filter((rule) => matches(rule)).map(String);
296 if (kind === "windows-portable-zip") {
297 const rootEntries = new Set(["Reasonix.exe", "reasonix-cli.exe", ...(portableLayout === "legacy-dual" ? ["reasonix-launcher.exe"] : [])]);
298 for (const name of names) {
299 if (!name.includes("/") && /\.(exe|dll)$/i.test(name) && !rootEntries.has(name) && !forbidden.includes(name)) forbidden.push(name);
300 }
301 }
302 return {
303 missing: spec.required.filter((rule) => !matches(rule)).map(String),
304 forbidden,
305 };
306 }
307
308 // GNU tar -tv and dpkg-deb -c share this column layout; bsdtar does not, so an
309 // unrecognised line fails instead of silently dropping the mode check.
310 const VERBOSE_LISTING = /^([-dl][rwxsStT-]{9})\s+(\S+)\s+\d+\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+(.*)$/;
311
312 export function parseVerboseListing(lines) {
313 return lines.map((line) => {
314 const match = VERBOSE_LISTING.exec(line);
315 if (!match) throw new Error(`unrecognised listing line: ${JSON.stringify(line)}`);
316 const [, mode, owner, rest] = match;
317 const name = mode.startsWith("l") ? rest.split(" -> ")[0] : rest;
318 return { mode, owner, name };
319 });
320 }
321
322 const DIRECTORY_MODE = /^drwxr-xr-x$/;
323
324 export function checkEntryModes(rows, kind) {
325 const errors = [];
326 for (const { mode, owner, name } of rows) {
327 if (mode.startsWith("l")) continue;
328 if (mode.startsWith("d") && !DIRECTORY_MODE.test(mode)) errors.push(`${name} has mode ${mode}; directories must be drwxr-xr-x`);
329 if (mode.startsWith("-") && mode[7] !== "r") errors.push(`${name} has mode ${mode}; files must be world-readable`);
330 if (kind === "linux-deb" && owner !== "root/root") errors.push(`${name} is owned by ${owner}; package members must be root/root`);
331 }
332 return errors;
333 }
334
335 export function validateMacServiceLink(appDir) {
336 const link = join(appDir, "Contents", "MacOS", PRODUCT.serviceExecutable);
337 const expectedTarget = `../Resources/service/${PRODUCT.serviceExecutable}`;
338 const errors = [];
339 let stat;
340 try {
341 stat = lstatSync(link);
342 } catch (error) {
343 return [`service compatibility link is unavailable: ${error.message}`];
344 }
345 if (!stat.isSymbolicLink()) return ["service compatibility path is not a symbolic link"];
346 const target = readlinkSync(link);
347 if (target !== expectedTarget) errors.push(`service compatibility link target is ${JSON.stringify(target)}, want ${JSON.stringify(expectedTarget)}`);
348 if (resolve(dirname(link), target) !== resolve(appDir, "Contents", "Resources", "service", PRODUCT.serviceExecutable)) {
349 errors.push("service compatibility link does not resolve to the package service entity");
350 }
351 try {
352 const realApp = realpathSync(appDir);
353 const realTarget = realpathSync(link);
354 const rel = relative(realApp, realTarget);
355 if (rel === "" || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
356 errors.push("service compatibility link resolves outside the application bundle");
357 }
358 if (!statSync(realTarget).isFile()) errors.push("service compatibility link target is not a regular file");
359 } catch (error) {
360 errors.push(`service compatibility link is dangling or cyclic: ${error.message}`);
361 }
362 return errors;
363 }
364
365 export function inferArtifactKind(pathname, isDirectory, entries = []) {
366 const name = basename(pathname);
367 if (isDirectory) {
368 if (name.endsWith(".app")) return "darwin-app-dir";
369 if (entries.includes(`${PRODUCT.executable}.exe`)) return "windows-app-dir";
370 if (entries.includes("chrome-sandbox")) return "linux-app-dir";
371 throw new Error(`cannot infer the artifact kind of directory ${pathname}`);
372 }
373 if (/^Reasonix-darwin-.*\.zip$/.test(name)) return "darwin-zip";
374 if (/^Reasonix-windows-.*\.zip$/.test(name)) return "windows-portable-zip";
375 if (name.endsWith(".tar.gz")) return "linux-tar";
376 if (name.endsWith(".deb")) return "linux-deb";
377 throw new Error(`cannot infer the artifact kind of ${pathname}`);
378 }
379
380 const EOCD = 0x06054b50;
381 const EOCD64_LOCATOR = 0x07064b50;
382 const EOCD64 = 0x06064b50;
383 const CENTRAL_HEADER = 0x02014b50;
384
385 // Only the central directory is read, so a 300 MB bundle costs a few reads;
386 // zip64 records are honoured because ditto emits them for large archives.
387 export function listZipEntries(file) {
388 return scanZip(file);
389 }
390
391 // Read a bounded member without extracting the archive or requiring a native
392 // unzip tool. Used only for small pointers and the stable launcher entries.
393 export function readZipMember(file, member) {
394 return scanZip(file, member);
395 }
396
397 function scanZip(file, member) {
398 const fd = openSync(file, "r");
399 try {
400 const size = fstatSync(fd).size;
401 const tailLength = Math.min(size, 22 + 65535);
402 const tail = Buffer.alloc(tailLength);
403 readSync(fd, tail, 0, tailLength, size - tailLength);
404 let eocd = -1;
405 for (let i = tailLength - 22; i >= 0; i--) {
406 if (tail.readUInt32LE(i) === EOCD) {
407 eocd = i;
408 break;
409 }
410 }
411 if (eocd < 0) throw new Error(`${file}: not a zip archive (no end-of-central-directory record)`);
412 let count = tail.readUInt16LE(eocd + 10);
413 let directorySize = tail.readUInt32LE(eocd + 12);
414 let directoryOffset = tail.readUInt32LE(eocd + 16);
415 const locator = eocd - 20;
416 if ((count === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) && locator >= 0 && tail.readUInt32LE(locator) === EOCD64_LOCATOR) {
417 const record = Buffer.alloc(56);
418 readSync(fd, record, 0, 56, Number(tail.readBigUInt64LE(locator + 8)));
419 if (record.readUInt32LE(0) !== EOCD64) throw new Error(`${file}: corrupt zip64 end-of-central-directory record`);
420 count = Number(record.readBigUInt64LE(32));
421 directorySize = Number(record.readBigUInt64LE(40));
422 directoryOffset = Number(record.readBigUInt64LE(48));
423 }
424 const directory = Buffer.alloc(directorySize);
425 readSync(fd, directory, 0, directorySize, directoryOffset);
426 const names = [];
427 let contents;
428 let offset = 0;
429 for (let i = 0; i < count; i++) {
430 if (directory.readUInt32LE(offset) !== CENTRAL_HEADER) throw new Error(`${file}: corrupt central directory at entry ${i}`);
431 const nameLength = directory.readUInt16LE(offset + 28);
432 const extraLength = directory.readUInt16LE(offset + 30);
433 const commentLength = directory.readUInt16LE(offset + 32);
434 const name = directory.toString("utf8", offset + 46, offset + 46 + nameLength);
435 names.push(name);
436 if (member !== undefined && normalizeEntry(name) === member) {
437 if (contents !== undefined) throw new Error(`duplicate zip member ${member}`);
438 let compressed = directory.readUInt32LE(offset + 20);
439 let uncompressed = directory.readUInt32LE(offset + 24);
440 let localOffset = directory.readUInt32LE(offset + 42);
441 const extraStart = offset + 46 + nameLength;
442 for (let pos = extraStart; pos + 4 <= extraStart + extraLength;) {
443 const tag = directory.readUInt16LE(pos), length = directory.readUInt16LE(pos + 2);
444 if (pos + 4 + length > extraStart + extraLength) throw new Error("invalid zip extra field");
445 if (tag === 1) {
446 let field = pos + 4;
447 const next = () => {
448 if (field + 8 > pos + 4 + length) throw new Error("invalid zip64 entry");
449 const value = Number(directory.readBigUInt64LE(field)); field += 8;
450 if (!Number.isSafeInteger(value)) throw new Error("zip64 value is too large");
451 return value;
452 };
453 if (uncompressed === 0xffffffff) uncompressed = next();
454 if (compressed === 0xffffffff) compressed = next();
455 if (localOffset === 0xffffffff) localOffset = next();
456 }
457 pos += 4 + length;
458 }
459 const limit = 32 * 1024 * 1024;
460 if (compressed > limit || uncompressed > limit || localOffset + 30 > size) throw new Error(`zip member ${member} exceeds bounds`);
461 if (directory.readUInt16LE(offset + 8) & 1) throw new Error("encrypted zip members are unsupported");
462 const local = Buffer.alloc(30);
463 readSync(fd, local, 0, local.length, localOffset);
464 if (local.readUInt32LE(0) !== 0x04034b50) throw new Error("invalid zip local header");
465 const method = directory.readUInt16LE(offset + 10);
466 if (local.readUInt16LE(8) !== method || (local.readUInt16LE(6) & 1)) throw new Error("zip local/central header mismatch");
467 const localName = Buffer.alloc(local.readUInt16LE(26));
468 readSync(fd, localName, 0, localName.length, localOffset + 30);
469 if (localName.toString("utf8") !== name) throw new Error("zip local/central name mismatch");
470 const start = localOffset + 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
471 if (start + compressed > directoryOffset) throw new Error("zip member overlaps central directory");
472 const packed = Buffer.alloc(compressed);
473 readSync(fd, packed, 0, packed.length, start);
474 if (method !== 0 && method !== 8) throw new Error(`unsupported zip compression ${method}`);
475 contents = method === 0 ? packed : inflateRawSync(packed, { maxOutputLength: limit });
476 if (contents.length !== uncompressed) throw new Error(`zip member ${member} size mismatch`);
477 }
478 offset += 46 + nameLength + extraLength + commentLength;
479 }
480 if (member !== undefined && contents === undefined) throw new Error(`zip member ${member} is missing`);
481 return member === undefined ? names : contents;
482 } finally {
483 closeSync(fd);
484 }
485 }
486
487 export function isDirectory(path) {
488 try {
489 return statSync(path).isDirectory();
490 } catch {
491 return false;
492 }
493 }
494
494 lines Plain Text