Initial commit
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const launcher = path.join(root, "runtime/start.sh.template");
|
||||
|
||||
function fixture() {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "nani-runtime-test-"));
|
||||
const app = path.join(directory, "app");
|
||||
const cache = path.join(directory, "cache");
|
||||
const argumentsFile = path.join(directory, "arguments");
|
||||
const launcherPathFile = path.join(directory, "launcher-path");
|
||||
mkdirSync(path.join(app, "resources"), { recursive: true });
|
||||
writeFileSync(path.join(app, "electron"), "#!/usr/bin/env bash\nprintf '%s\\n' \"$@\" >\"$ARGUMENTS_FILE\"\nprintf '%s\\n' \"$NANI_LAUNCHER_PATH\" >\"$LAUNCHER_PATH_FILE\"\n");
|
||||
chmodSync(path.join(app, "electron"), 0o755);
|
||||
return { directory, app, cache, argumentsFile, launcherPathFile };
|
||||
}
|
||||
|
||||
function runLauncher(setup, args = [], environment = {}) {
|
||||
execFileSync("bash", [launcher, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
DISPLAY: "",
|
||||
WAYLAND_DISPLAY: "",
|
||||
NANI_APP_DIR: setup.app,
|
||||
XDG_CACHE_HOME: setup.cache,
|
||||
ARGUMENTS_FILE: setup.argumentsFile,
|
||||
LAUNCHER_PATH_FILE: setup.launcherPathFile,
|
||||
...environment,
|
||||
},
|
||||
});
|
||||
return readFileSync(setup.argumentsFile, "utf8").trim().split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
test("launcher selects X11 when both display servers are available", () => {
|
||||
const setup = fixture();
|
||||
assert.deepEqual(runLauncher(setup, [], { DISPLAY: ":0", WAYLAND_DISPLAY: "wayland-0" }), [
|
||||
"--ozone-platform=x11",
|
||||
]);
|
||||
});
|
||||
|
||||
test("explicit ozone CLI argument wins and URL arguments pass through", () => {
|
||||
const setup = fixture();
|
||||
const url = "https://example.com/translate?q=hello";
|
||||
assert.deepEqual(runLauncher(setup, ["--ozone-platform=wayland", url]), [
|
||||
"--ozone-platform=wayland",
|
||||
url,
|
||||
]);
|
||||
});
|
||||
|
||||
test("launcher limits optional CDP to localhost", () => {
|
||||
const setup = fixture();
|
||||
assert.deepEqual(runLauncher(setup, [], { NANI_CDP_PORT: "9222" }), [
|
||||
"--ozone-platform=x11",
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
"--remote-debugging-port=9222",
|
||||
]);
|
||||
});
|
||||
|
||||
test("launcher exports a canonical local path and prefers the host AppImage path", () => {
|
||||
const setup = fixture();
|
||||
runLauncher(setup);
|
||||
assert.equal(readFileSync(setup.launcherPathFile, "utf8").trim(), launcher);
|
||||
|
||||
const appImage = path.join(setup.directory, "Nani.AppImage");
|
||||
writeFileSync(appImage, "image");
|
||||
runLauncher(setup, [], { APPIMAGE: appImage });
|
||||
assert.equal(readFileSync(setup.launcherPathFile, "utf8").trim(), appImage);
|
||||
});
|
||||
|
||||
test("launcher rejects invalid platform and CDP settings", () => {
|
||||
const setup = fixture();
|
||||
assert.throws(() => runLauncher(setup, [], { NANI_OZONE_PLATFORM: "invalid" }));
|
||||
assert.throws(() => runLauncher(setup, [], { NANI_CDP_PORT: "70000" }));
|
||||
});
|
||||
|
||||
test("AppImage payload is staged under usr/lib/nani", () => {
|
||||
const setup = fixture();
|
||||
writeFileSync(path.join(setup.app, "resources/app.asar"), "asar");
|
||||
writeFileSync(path.join(setup.app, "resources/icon.png"), "png");
|
||||
writeFileSync(path.join(setup.app, "patch-report.json"), "{}\n");
|
||||
writeFileSync(path.join(setup.app, "build-info.env"), "version=1.2.3\n");
|
||||
|
||||
const tool = path.join(setup.directory, "appimagetool");
|
||||
const captured = path.join(setup.directory, "AppDir");
|
||||
writeFileSync(tool, `#!/usr/bin/env bash\ncp -a \"$1\" ${JSON.stringify(captured)}\nprintf image >\"$2\"\n`);
|
||||
chmodSync(tool, 0o755);
|
||||
const output = path.join(setup.directory, "Nani.AppImage");
|
||||
execFileSync("bash", [path.join(root, "scripts/build-appimage.sh"), setup.app, output], {
|
||||
env: { ...process.env, APPIMAGETOOL: tool, NANI_BUILD_DIR: path.join(setup.directory, "build") },
|
||||
});
|
||||
|
||||
assert.equal(existsSync(output), true);
|
||||
assert.equal(existsSync(path.join(captured, "usr/lib/nani/electron")), true);
|
||||
assert.equal(existsSync(path.join(captured, "usr/lib/nani/resources/app.asar")), true);
|
||||
assert.equal(existsSync(path.join(captured, "usr/lib/nani/patch-report.json")), true);
|
||||
assert.equal(readFileSync(path.join(captured, "usr/bin/nani"), "utf8"), readFileSync(path.join(captured, "usr/lib/nani/nani"), "utf8"));
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
const resolver = path.resolve("scripts/lib/resolve-upstream.mjs");
|
||||
|
||||
function withManifest(content, callback) {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "nani-manifest-test-"));
|
||||
try {
|
||||
const manifest = path.join(directory, "latest-mac.yml");
|
||||
writeFileSync(manifest, content);
|
||||
return callback(manifest);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("selects the DMG entry instead of the updater primary zip", () => {
|
||||
withManifest(
|
||||
[
|
||||
"version: 1.1.0",
|
||||
"files:",
|
||||
" - url: Nani-1.1.0-arm64-mac.zip",
|
||||
" sha512: zip-hash",
|
||||
" - url: nani-1.1.0.dmg",
|
||||
" sha512: dmg-hash",
|
||||
"path: Nani-1.1.0-arm64-mac.zip",
|
||||
"sha512: zip-hash",
|
||||
"",
|
||||
].join("\n"),
|
||||
(manifest) => {
|
||||
const result = spawnSync(process.execPath, [resolver, manifest, "https://cdn.example/artifacts/latest-mac.yml"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(
|
||||
result.stdout,
|
||||
"1.1.0\thttps://cdn.example/artifacts/nani-1.1.0.dmg\tdmg-hash\tnani-1.1.0.dmg\n",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects ambiguous DMG entries", () => {
|
||||
withManifest(
|
||||
"version: 1\nfiles:\n - {url: one.dmg, sha512: a}\n - {url: two.dmg, sha512: b}\n",
|
||||
(manifest) => {
|
||||
const result = spawnSync(process.execPath, [resolver, manifest, "https://cdn.example/latest.yml"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /expected exactly one DMG/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a non-HTTPS resolved URL", () => {
|
||||
withManifest(
|
||||
"version: '1'\nfiles:\n - {url: http://cdn.example/nani.dmg, sha512: a}\n",
|
||||
(manifest) => {
|
||||
const result = spawnSync(process.execPath, [resolver, manifest, "https://cdn.example/latest.yml"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /must use HTTPS/);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user