import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { prepareExecutablePayloads, verifyExecutablePayloads, XSEL_RELATIVE_PATH, } from "../scripts/patch-asar.mjs"; import { normalizeDescriptors, PHASE_MAIN_BUNDLE } from "../scripts/patches/descriptor.mjs"; import disableUpdater, { UPDATER_ENABLE_ANCHOR, UPDATER_HANDLERS_ANCHOR, UPDATER_MARKER, } from "../scripts/patches/disable-updater.mjs"; import { runPatchDescriptors } from "../scripts/patches/engine.mjs"; import linuxOnboarding, { ONBOARDING_ANCHOR, verifyLinuxOnboarding, } from "../scripts/patches/linux-onboarding.mjs"; import linuxTray, { TRAY_ANCHOR, TRAY_MARKER } from "../scripts/patches/linux-tray.mjs"; import ocrStub, { verifyOcrStub } from "../scripts/patches/ocr-stub.mjs"; import pruneNonLinux, { NON_LINUX_PAYLOADS, verifyNonLinuxHeaderEntries, verifyNonLinuxPayloadsAbsent, } from "../scripts/patches/prune-non-linux.mjs"; import xdgAutostart, { AUTOSTART_GET_ANCHOR, AUTOSTART_MARKER, AUTOSTART_SET_ANCHOR, } from "../scripts/patches/xdg-autostart.mjs"; function temporaryDirectory(t) { const root = mkdtempSync(path.join(os.tmpdir(), "nani-asar-test-")); t.after(() => rmSync(root, { recursive: true, force: true })); return root; } test("descriptors have deterministic phase/order and reject duplicate ids", () => { const ordered = normalizeDescriptors([ xdgAutostart, disableUpdater, linuxTray, ocrStub, pruneNonLinux, linuxOnboarding, ]); assert.deepEqual(ordered.map(({ id }) => id), [ "prune-non-linux", "ocr-stub", "linux-onboarding", "linux-tray", "disable-updater", "xdg-autostart", ]); assert.throws(() => normalizeDescriptors([linuxTray, linuxTray]), /Duplicate patch descriptor/); }); test("Nani 1.1.0 semantic anchors apply once and are idempotent", () => { let source = [ UPDATER_ENABLE_ANCHOR, TRAY_ANCHOR, AUTOSTART_GET_ANCHOR, AUTOSTART_SET_ANCHOR, UPDATER_HANDLERS_ANCHOR, ].join(";"); for (const descriptor of [linuxTray, disableUpdater, xdgAutostart]) { const result = descriptor.apply({ source }); assert.equal(result.status, "applied"); source = result.source; const again = descriptor.apply({ source }); assert.equal(again.status, "already-applied"); assert.equal(again.source, source); } assert.match(source, new RegExp(TRAY_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); assert.match(source, new RegExp(UPDATER_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); assert.match(source, new RegExp(AUTOSTART_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); assert.match(source, /process\.platform==="linux"\?async\(\)=>!1:xa/); assert.match(source, /nativeImage\.createFromPath/); assert.match(source, /NANI_LAUNCHER_PATH/); }); test("required anchor drift is reported and main bundle remains unchanged", () => { const root = mkdtempSync(path.join(os.tmpdir(), "nani-asar-test-")); try { const mainBundlePath = path.join(root, "main.js"); writeFileSync(mainBundlePath, "upstream drift"); const result = runPatchDescriptors({ extractedDir: root, mainBundlePath, descriptors: [linuxTray], }); assert.equal(result.hasRequiredFailure, true); assert.equal(result.entries[0].status, "failed-required"); assert.equal(readFileSync(mainBundlePath, "utf8"), "upstream drift"); } finally { rmSync(root, { recursive: true, force: true }); } }); test("optional anchor drift is fail-soft", (t) => { const root = temporaryDirectory(t); const mainBundlePath = path.join(root, "main.js"); writeFileSync(mainBundlePath, "upstream drift"); const result = runPatchDescriptors({ extractedDir: root, mainBundlePath, descriptors: [xdgAutostart], }); assert.equal(result.hasRequiredFailure, false); assert.equal(result.entries[0].status, "skipped-optional"); }); test("OCR descriptor installs the loadable Linux package stub", async (t) => { const root = temporaryDirectory(t); mkdirSync(path.join(root, "node_modules"), { recursive: true }); assert.equal(ocrStub.apply({ extractedDir: root }).status, "applied"); assert.equal(ocrStub.apply({ extractedDir: root }).status, "already-applied"); verifyOcrStub(root); const stub = await import(path.join(root, "node_modules/@napi-rs/system-ocr-linux-x64-gnu/index.js")); assert.equal(stub.default.OcrAccuracy.Accurate, 1); await assert.rejects(stub.default.recognize(), /not supported/); }); test("Linux onboarding patch updates renderer and all dictionaries", (t) => { const root = temporaryDirectory(t); const renderer = path.join(root, "out/renderer/assets/index-test.js"); mkdirSync(path.dirname(renderer), { recursive: true }); writeFileSync(renderer, ONBOARDING_ANCHOR); const translations = { en: "Welcome to Nani for Mac!", ja: "NaniのMacアプリへようこそ!", ko: "Nani Mac 앱에 오신 걸 환영해요!", zh: "欢迎来到 Nani 的 Mac 应用!", }; for (const [language, translation] of Object.entries(translations)) { const file = path.join( root, "node_modules/repo-lib/dist/dictionaries/default", `${language}.mjs`, ); mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, `welcomeToMacApp:\`${translation}\`,welcomeToWinApp:\`Windows\``); } assert.equal(linuxOnboarding.apply({ extractedDir: root }).status, "applied"); assert.doesNotThrow(() => verifyLinuxOnboarding(root)); assert.equal(linuxOnboarding.apply({ extractedDir: root }).status, "already-applied"); }); test("non-Linux payload pruning is required and idempotent", (t) => { const root = temporaryDirectory(t); for (const relativePath of NON_LINUX_PAYLOADS) { const target = path.join(root, relativePath); mkdirSync(path.dirname(target), { recursive: true }); if (path.extname(target)) writeFileSync(target, "non-linux"); else { mkdirSync(target, { recursive: true }); writeFileSync(path.join(target, "payload"), "non-linux"); } } const first = pruneNonLinux.apply({ extractedDir: root }); assert.equal(first.status, "applied"); assert.match(first.detail, /removed 7/); assert.doesNotThrow(() => verifyNonLinuxPayloadsAbsent(root)); assert.equal(pruneNonLinux.apply({ extractedDir: root }).status, "already-applied"); assert.doesNotThrow(() => verifyNonLinuxHeaderEntries(["/package.json", "/out/main/index.js"])); assert.throws( () => verifyNonLinuxHeaderEntries([ "/node_modules/@nut-tree-fork/libnut-darwin/build/Release/libnut.node", ]), /remain in ASAR header/, ); }); test("engine rolls back an extracted-app descriptor that throws", (t) => { const root = temporaryDirectory(t); const mainBundlePath = path.join(root, "main.js"); writeFileSync(mainBundlePath, "main"); const descriptor = { id: "rollback-test", phase: "extracted-app", order: 1, required: true, apply({ extractedDir }) { writeFileSync(path.join(extractedDir, "partial"), "bad"); throw new Error("boom"); }, }; const result = runPatchDescriptors({ extractedDir: root, mainBundlePath, descriptors: [descriptor] }); assert.equal(result.entries[0].status, "failed-required"); assert.throws(() => readFileSync(path.join(root, "partial")), /ENOENT/); }); test("descriptor contract rejects missing required and invalid phase", () => { const base = { id: "bad", order: 1, apply() {} }; assert.throws( () => normalizeDescriptors([{ ...base, phase: PHASE_MAIN_BUNDLE }]), /required must be a boolean/, ); assert.throws( () => normalizeDescriptors([{ ...base, required: true, phase: "third-phase" }]), /unsupported phase/, ); }); test("xsel stays unpacked, byte-identical, and executable", (t) => { const root = temporaryDirectory(t); const extracted = path.join(root, "extracted"); const unpacked = path.join(root, "app.asar.unpacked"); const verified = path.join(root, "verified"); const source = path.join(extracted, XSEL_RELATIVE_PATH); const unpackedXsel = path.join(unpacked, XSEL_RELATIVE_PATH); const verifiedXsel = path.join(verified, XSEL_RELATIVE_PATH); mkdirSync(path.dirname(source), { recursive: true }); mkdirSync(path.dirname(unpackedXsel), { recursive: true }); mkdirSync(path.dirname(verifiedXsel), { recursive: true }); writeFileSync(source, "synthetic-xsel", { mode: 0o644 }); const payloads = prepareExecutablePayloads(extracted); assert.notEqual(payloads[XSEL_RELATIVE_PATH].mode & 0o111, 0); writeFileSync(unpackedXsel, readFileSync(source), { mode: payloads[XSEL_RELATIVE_PATH].mode }); writeFileSync(verifiedXsel, readFileSync(source), { mode: payloads[XSEL_RELATIVE_PATH].mode }); assert.doesNotThrow(() => verifyExecutablePayloads(unpacked, verified, payloads)); writeFileSync(verifiedXsel, "changed", { mode: 0o755 }); assert.throws( () => verifyExecutablePayloads(unpacked, verified, payloads), /payload bytes changed/, ); });