Initial commit

This commit is contained in:
2026-08-01 20:21:37 +09:00
commit 845dde50ad
38 changed files with 2922 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
export const PHASE_EXTRACTED_APP = "extracted-app";
export const PHASE_MAIN_BUNDLE = "main-bundle";
export const PATCH_PHASES = [PHASE_EXTRACTED_APP, PHASE_MAIN_BUNDLE];
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
export function definePatch(descriptor) {
if (descriptor == null || typeof descriptor !== "object" || Array.isArray(descriptor)) {
throw new TypeError("Patch descriptor must be an object");
}
if (typeof descriptor.id !== "string" || !ID_PATTERN.test(descriptor.id)) {
throw new TypeError(`Patch descriptor id must match ${ID_PATTERN}`);
}
if (!PATCH_PHASES.includes(descriptor.phase)) {
throw new TypeError(`Patch '${descriptor.id}' has unsupported phase '${descriptor.phase}'`);
}
if (!Number.isInteger(descriptor.order)) {
throw new TypeError(`Patch '${descriptor.id}' order must be an integer`);
}
if (typeof descriptor.required !== "boolean") {
throw new TypeError(`Patch '${descriptor.id}' required must be a boolean`);
}
if (typeof descriptor.apply !== "function") {
throw new TypeError(`Patch '${descriptor.id}' must export an apply function`);
}
return Object.freeze({ ...descriptor });
}
export function normalizeDescriptors(descriptors) {
const normalized = descriptors.map(definePatch);
const seen = new Set();
for (const descriptor of normalized) {
if (seen.has(descriptor.id)) {
throw new Error(`Duplicate patch descriptor id '${descriptor.id}'`);
}
seen.add(descriptor.id);
}
return [...normalized].sort((left, right) => {
const phaseOrder = PATCH_PHASES.indexOf(left.phase) - PATCH_PHASES.indexOf(right.phase);
return phaseOrder || left.order - right.order || left.id.localeCompare(right.id);
});
}
+30
View File
@@ -0,0 +1,30 @@
import { definePatch, PHASE_MAIN_BUNDLE } from "./descriptor.mjs";
import { markerStatus, replaceExactly } from "./lib.mjs";
export const UPDATER_MARKER = "/* nani-linux:disable-updater */";
export const UPDATER_ENABLE_ANCHOR = 'const ue=process.platform!=="win32"';
export const UPDATER_HANDLERS_ANCHOR = ",ro=Ue,ao=Ca,so=In,oo=xa,io=";
export default definePatch({
id: "disable-updater",
phase: PHASE_MAIN_BUNDLE,
order: 20,
required: true,
apply({ source }) {
const status = markerStatus(source, UPDATER_MARKER);
if (status) return { source, status };
let patched = replaceExactly(
source,
UPDATER_ENABLE_ANCHOR,
`const ue=process.platform==="darwin"${UPDATER_MARKER}`,
"updater platform predicate",
);
patched = replaceExactly(
patched,
UPDATER_HANDLERS_ANCHOR,
',ro=process.platform==="linux"?async()=>void 0:Ue,ao=Ca,so=process.platform==="linux"?()=>void 0:In,oo=process.platform==="linux"?async()=>!1:xa,io=',
"updater IPC handlers",
);
return { source: patched, status: "applied" };
},
});
+100
View File
@@ -0,0 +1,100 @@
import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { normalizeDescriptors, PHASE_EXTRACTED_APP, PHASE_MAIN_BUNDLE } from "./descriptor.mjs";
function reportEntry(descriptor, status, detail = null) {
return {
id: descriptor.id,
phase: descriptor.phase,
order: descriptor.order,
required: descriptor.required,
status,
...(detail ? { detail } : {}),
};
}
function validateResult(descriptor, result) {
if (result == null || typeof result !== "object") {
throw new Error(`Patch '${descriptor.id}' returned no result`);
}
if (result.status !== "applied" && result.status !== "already-applied") {
throw new Error(`Patch '${descriptor.id}' returned invalid status '${result.status}'`);
}
if (descriptor.phase === PHASE_MAIN_BUNDLE && typeof result.source !== "string") {
throw new Error(`Main-bundle patch '${descriptor.id}' must return source`);
}
return result;
}
export function runPatchDescriptors({ extractedDir, mainBundlePath, descriptors }) {
const ordered = normalizeDescriptors(descriptors);
const entries = [];
let mainSource = readFileSync(mainBundlePath, "utf8");
let hasRequiredFailure = false;
let hasIntegrityFailure = false;
for (const descriptor of ordered) {
const snapshot = descriptor.phase === PHASE_EXTRACTED_APP
? `${extractedDir}.rollback-${descriptor.id}`
: mainSource;
try {
if (descriptor.phase === PHASE_EXTRACTED_APP) {
rmSync(snapshot, { recursive: true, force: true });
cpSync(extractedDir, snapshot, { recursive: true, dereference: false });
}
const result = validateResult(
descriptor,
descriptor.apply(
descriptor.phase === PHASE_MAIN_BUNDLE
? { extractedDir, mainBundlePath, source: mainSource }
: { extractedDir, mainBundlePath },
),
);
if (descriptor.phase === PHASE_MAIN_BUNDLE) mainSource = result.source;
entries.push(reportEntry(descriptor, result.status, result.detail));
} catch (error) {
try {
if (descriptor.phase === PHASE_EXTRACTED_APP && existsSync(snapshot)) {
rmSync(extractedDir, { recursive: true, force: true });
cpSync(snapshot, extractedDir, { recursive: true, dereference: false });
}
} catch (rollbackError) {
hasIntegrityFailure = true;
entries.push(reportEntry(
descriptor,
"failed-integrity",
`${error.message}; rollback failed: ${rollbackError.message}`,
));
continue;
}
const status = descriptor.required ? "failed-required" : "skipped-optional";
if (descriptor.required) hasRequiredFailure = true;
entries.push(reportEntry(descriptor, status, error instanceof Error ? error.message : String(error)));
} finally {
if (descriptor.phase === PHASE_EXTRACTED_APP) {
rmSync(snapshot, { recursive: true, force: true });
}
}
}
if (!hasRequiredFailure && !hasIntegrityFailure) {
writeFileSync(mainBundlePath, mainSource);
}
return { entries, hasRequiredFailure, hasIntegrityFailure, mainSource };
}
export function addIntegrityFailure(entries, id, detail) {
entries.push({
id,
phase: "post-repack",
order: 0,
required: true,
status: "failed-integrity",
detail,
});
}
+15
View File
@@ -0,0 +1,15 @@
import disableUpdater from "./disable-updater.mjs";
import linuxOnboarding from "./linux-onboarding.mjs";
import linuxTray from "./linux-tray.mjs";
import ocrStub from "./ocr-stub.mjs";
import pruneNonLinux from "./prune-non-linux.mjs";
import xdgAutostart from "./xdg-autostart.mjs";
export const corePatchDescriptors = [
pruneNonLinux,
ocrStub,
linuxOnboarding,
linuxTray,
disableUpdater,
xdgAutostart,
];
+66
View File
@@ -0,0 +1,66 @@
import { createHash } from "node:crypto";
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
export function countOccurrences(source, needle) {
if (needle.length === 0) throw new Error("Patch needle must not be empty");
let count = 0;
let offset = 0;
while ((offset = source.indexOf(needle, offset)) !== -1) {
count += 1;
offset += needle.length;
}
return count;
}
export function replaceExactly(source, needle, replacement, label) {
const matches = countOccurrences(source, needle);
if (matches !== 1) {
throw new Error(`${label}: expected exactly one semantic anchor, found ${matches}`);
}
return source.replace(needle, replacement);
}
export function markerStatus(source, marker) {
const matches = countOccurrences(source, marker);
if (matches > 1) throw new Error(`Patch marker '${marker}' occurs ${matches} times`);
return matches === 1 ? "already-applied" : null;
}
export function copyTreeContents(sourceDir, destinationDir) {
if (!existsSync(sourceDir) || !statSync(sourceDir).isDirectory()) {
throw new Error(`Replacement tree is not a directory: ${sourceDir}`);
}
mkdirSync(destinationDir, { recursive: true });
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
cpSync(path.join(sourceDir, entry.name), path.join(destinationDir, entry.name), {
recursive: true,
force: true,
dereference: false,
});
}
}
export function sha256File(filePath) {
return createHash("sha256").update(readFileSync(filePath)).digest("hex");
}
export function walkFiles(root) {
if (!existsSync(root)) return [];
const files = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) visit(fullPath);
else if (entry.isFile()) files.push(fullPath);
}
};
visit(root);
return files.sort((left, right) => left.localeCompare(right));
}
export function relativeFileHashes(root) {
return Object.fromEntries(
walkFiles(root).map((filePath) => [path.relative(root, filePath), sha256File(filePath)]),
);
}
+88
View File
@@ -0,0 +1,88 @@
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { definePatch, PHASE_EXTRACTED_APP } from "./descriptor.mjs";
import { replaceExactly, walkFiles } from "./lib.mjs";
export const ONBOARDING_MARKER = "/* nani-linux:onboarding */";
export const ONBOARDING_ANCHOR =
'window.constants.os==="win32"?e("welcomeToWinApp"):e("welcomeToMacApp")';
const ONBOARDING_REPLACEMENT =
`window.constants.os==="linux"?e("welcomeToLinuxApp")${ONBOARDING_MARKER}:` +
ONBOARDING_ANCHOR;
const TRANSLATIONS = {
en: "Welcome to Nani for Linux!",
ja: "NaniのLinuxアプリへようこそ!",
ko: "Nani Linux 앱에 오신 걸 환영해요!",
zh: "欢迎来到 Nani 的 Linux 应用!",
};
function dictionaryPath(root, language) {
return path.join(
root,
"node_modules/repo-lib/dist/dictionaries/default",
`${language}.mjs`,
);
}
export function verifyLinuxOnboarding(root) {
const rendererMatches = walkFiles(path.join(root, "out/renderer/assets"))
.filter((file) => file.endsWith(".js"))
.filter((file) => readFileSync(file, "utf8").includes(ONBOARDING_MARKER));
if (rendererMatches.length !== 1) {
throw new Error(`Expected one Linux onboarding renderer marker, found ${rendererMatches.length}`);
}
for (const [language, translation] of Object.entries(TRANSLATIONS)) {
const source = readFileSync(dictionaryPath(root, language), "utf8");
if (!source.includes(`welcomeToLinuxApp:\`${translation}\``)) {
throw new Error(`Linux onboarding translation is missing: ${language}`);
}
}
}
export default definePatch({
id: "linux-onboarding",
phase: PHASE_EXTRACTED_APP,
order: 20,
required: true,
apply({ extractedDir }) {
const rendererFiles = walkFiles(path.join(extractedDir, "out/renderer/assets"))
.filter((file) => file.endsWith(".js"));
const marked = rendererFiles.filter((file) =>
readFileSync(file, "utf8").includes(ONBOARDING_MARKER));
if (marked.length === 1) {
verifyLinuxOnboarding(extractedDir);
return { status: "already-applied" };
}
if (marked.length > 0) throw new Error("Linux onboarding patch is partially applied");
const matches = rendererFiles.filter((file) =>
readFileSync(file, "utf8").includes(ONBOARDING_ANCHOR));
if (matches.length !== 1) {
throw new Error(`Expected one Linux onboarding anchor, found ${matches.length}`);
}
const rendererSource = readFileSync(matches[0], "utf8");
writeFileSync(
matches[0],
replaceExactly(rendererSource, ONBOARDING_ANCHOR, ONBOARDING_REPLACEMENT, "Linux onboarding"),
);
for (const [language, translation] of Object.entries(TRANSLATIONS)) {
const file = dictionaryPath(extractedDir, language);
const source = readFileSync(file, "utf8");
const anchor = "welcomeToWinApp:";
writeFileSync(
file,
replaceExactly(
source,
anchor,
`welcomeToLinuxApp:\`${translation}\`,${anchor}`,
`Linux onboarding dictionary (${language})`,
),
);
}
verifyLinuxOnboarding(extractedDir);
return { status: "applied" };
},
});
+21
View File
@@ -0,0 +1,21 @@
import { definePatch, PHASE_MAIN_BUNDLE } from "./descriptor.mjs";
import { markerStatus, replaceExactly } from "./lib.mjs";
export const TRAY_MARKER = "/* nani-linux:linux-tray */";
export const TRAY_ANCHOR = 'function Ls(e){if(!it()&&process.platform==="win32"){const t=new c.Tray(S.join(__dirname,"../../resources/tray-icon-win.ico"));Kn(t),Ce(t,e);return}Ce(null,e)}';
const TRAY_REPLACEMENT = `function Ls(e){${TRAY_MARKER}if(!it()&&(process.platform==="win32"||process.platform==="linux")){const t=new c.Tray(process.platform==="linux"?c.nativeImage.createFromPath(S.join(__dirname,"../../resources/icon.png")).resize({width:24,height:24}):S.join(__dirname,"../../resources/tray-icon-win.ico"));Kn(t),Ce(t,e);return}Ce(null,e)}`;
export default definePatch({
id: "linux-tray",
phase: PHASE_MAIN_BUNDLE,
order: 10,
required: true,
apply({ source }) {
const status = markerStatus(source, TRAY_MARKER);
if (status) return { source, status };
return {
source: replaceExactly(source, TRAY_ANCHOR, TRAY_REPLACEMENT, "Linux tray"),
status: "applied",
};
},
});
+44
View File
@@ -0,0 +1,44 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { definePatch, PHASE_EXTRACTED_APP } from "./descriptor.mjs";
import { copyTreeContents, relativeFileHashes } from "./lib.mjs";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
export const OCR_STUB_SOURCE = path.resolve(
moduleDir,
"../../stubs/@napi-rs/system-ocr-linux-x64-gnu",
);
export const OCR_STUB_RELATIVE = "node_modules/@napi-rs/system-ocr-linux-x64-gnu";
export function verifyOcrStub(extractedDir) {
const destination = path.join(extractedDir, OCR_STUB_RELATIVE);
if (!existsSync(destination)) throw new Error("Linux OCR stub is missing after repack");
const expected = relativeFileHashes(OCR_STUB_SOURCE);
const actual = relativeFileHashes(destination);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error("Linux OCR stub differs from the repository stub");
}
const packageJson = JSON.parse(readFileSync(path.join(destination, "package.json"), "utf8"));
if (packageJson.name !== "@napi-rs/system-ocr-linux-x64-gnu") {
throw new Error("Linux OCR stub package name is invalid");
}
}
export default definePatch({
id: "ocr-stub",
phase: PHASE_EXTRACTED_APP,
order: 10,
required: true,
apply({ extractedDir }) {
const destination = path.join(extractedDir, OCR_STUB_RELATIVE);
if (existsSync(destination)) {
verifyOcrStub(extractedDir);
return { status: "already-applied", detail: OCR_STUB_RELATIVE };
}
copyTreeContents(OCR_STUB_SOURCE, destination);
verifyOcrStub(extractedDir);
return { status: "applied", detail: OCR_STUB_RELATIVE };
},
});
+54
View File
@@ -0,0 +1,54 @@
import { existsSync, rmSync } from "node:fs";
import path from "node:path";
import { definePatch, PHASE_EXTRACTED_APP } from "./descriptor.mjs";
export const NON_LINUX_PAYLOADS = [
"native/build/Release/maccopy.node",
"native/build/Release/macos26_window_corner.node",
"node_modules/@napi-rs/system-ocr-darwin-arm64",
"node_modules/@nut-tree-fork/libnut-darwin",
"node_modules/@nut-tree-fork/libnut-win32",
"node_modules/@nut-tree-fork/node-mac-permissions",
"bin/language-helper",
];
export function verifyNonLinuxPayloadsAbsent(root) {
const remaining = NON_LINUX_PAYLOADS.filter((relativePath) =>
existsSync(path.join(root, relativePath)),
);
if (remaining.length > 0) {
throw new Error(`Non-Linux payloads remain: ${remaining.join(", ")}`);
}
}
export function verifyNonLinuxHeaderEntries(entries) {
const normalized = entries.map((entry) => entry.replace(/^\//, ""));
const remaining = NON_LINUX_PAYLOADS.filter((forbidden) =>
normalized.some((entry) => entry === forbidden || entry.startsWith(`${forbidden}/`)),
);
if (remaining.length > 0) {
throw new Error(`Non-Linux payloads remain in ASAR header: ${remaining.join(", ")}`);
}
}
export default definePatch({
id: "prune-non-linux",
phase: PHASE_EXTRACTED_APP,
order: 5,
required: true,
apply({ extractedDir }) {
const removed = [];
for (const relativePath of NON_LINUX_PAYLOADS) {
const target = path.join(extractedDir, relativePath);
if (!existsSync(target)) continue;
rmSync(target, { recursive: true, force: true });
removed.push(relativePath);
}
verifyNonLinuxPayloadsAbsent(extractedDir);
return {
status: removed.length > 0 ? "applied" : "already-applied",
detail: removed.length > 0 ? `removed ${removed.length} non-Linux payloads` : undefined,
};
},
});
+34
View File
@@ -0,0 +1,34 @@
import { definePatch, PHASE_MAIN_BUNDLE } from "./descriptor.mjs";
import { markerStatus, replaceExactly } from "./lib.mjs";
export const AUTOSTART_MARKER = "/* nani-linux:xdg-autostart */";
export const AUTOSTART_GET_ANCHOR = ',bi=(async()=>({autoLaunchEnabled:c.app.getLoginItemSettings().openAtLogin})),Ai="ms-settings:startupapps";';
export const AUTOSTART_SET_ANCHOR = 'const Mi=(async(e,t)=>{if(process.platform==="win32")return await vi();try{return c.app.setLoginItemSettings({openAtLogin:t,openAsHidden:!0}),{ok:!0}}catch(n){return{ok:!1,message:n instanceof Error?n.message:"Unknown error"}}})';
const AUTOSTART_GET_REPLACEMENT = String.raw`,$naniAutostartPath=()=>S.join(process.env.XDG_CONFIG_HOME||S.join(c.app.getPath("home"),".config"),"autostart","nani.desktop"),$naniDesktopQuote=e=>'"'+e.replace(/([\\"\u0060$])/g,"\\$1")+'"',$naniGetAutostart=async()=>te.access($naniAutostartPath()).then(()=>!0,()=>!1),$naniSetAutostart=async e=>{const t=$naniAutostartPath();if(!e)return await te.rm(t,{force:!0}),{ok:!0};const n=process.env.NANI_LAUNCHER_PATH;if(!n||!S.isAbsolute(n)||/[\r\n]/.test(n))return{ok:!1,message:"NANI_LAUNCHER_PATH must be an absolute path"};await te.mkdir(S.dirname(t),{recursive:!0});const r=t+".tmp-"+process.pid+"-"+Date.now(),a="[Desktop Entry]\nType=Application\nName=Nani Translate\nExec="+$naniDesktopQuote(n)+" --autostart\nTerminal=false\nX-GNOME-Autostart-enabled=true\n";try{return await te.writeFile(r,a,{mode:420}),await te.rename(r,t),{ok:!0}}finally{await te.rm(r,{force:!0}).catch(()=>{})}},bi=(async()=>process.platform==="linux"?({autoLaunchEnabled:await $naniGetAutostart()}):({autoLaunchEnabled:c.app.getLoginItemSettings().openAtLogin}))` + AUTOSTART_MARKER + ',Ai="ms-settings:startupapps";';
const AUTOSTART_SET_REPLACEMENT = 'const Mi=(async(e,t)=>{if(process.platform==="win32")return await vi();try{return process.platform==="linux"?await $naniSetAutostart(t):(c.app.setLoginItemSettings({openAtLogin:t,openAsHidden:!0}),{ok:!0})}catch(n){return{ok:!1,message:n instanceof Error?n.message:"Unknown error"}}})';
export default definePatch({
id: "xdg-autostart",
phase: PHASE_MAIN_BUNDLE,
order: 30,
required: false,
apply({ source }) {
const status = markerStatus(source, AUTOSTART_MARKER);
if (status) return { source, status };
let patched = replaceExactly(
source,
AUTOSTART_GET_ANCHOR,
AUTOSTART_GET_REPLACEMENT,
"XDG autostart getter",
);
patched = replaceExactly(
patched,
AUTOSTART_SET_ANCHOR,
AUTOSTART_SET_REPLACEMENT,
"XDG autostart setter",
);
return { source: patched, status: "applied" };
},
});