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
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { extractAll } from "@electron/asar";
function usage(message) {
if (message) console.error(`error: ${message}`);
console.error("usage: node scripts/analyze-upstream.mjs --app <Nani.app> [--dmg <file>] [--output <file>]");
process.exit(2);
}
function parseArgs(argv) {
const result = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!["--app", "--dmg", "--output"].includes(arg)) usage(`unknown argument: ${arg}`);
const value = argv[++index];
if (!value) usage(`${arg} requires a value`);
result[arg.slice(2)] = path.resolve(value);
}
if (!result.app) usage("--app is required");
return result;
}
async function sha512(file) {
const data = await readFile(file);
return createHash("sha512").update(data).digest("base64");
}
function plistValue(source, key) {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return source.match(new RegExp(`<key>${escaped}</key>\\s*<string>([^<]+)</string>`))?.[1] ?? null;
}
function countMatches(source, pattern) {
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
return [...source.matchAll(new RegExp(pattern.source, flags))].length;
}
function binaryKind(buffer) {
if (buffer.length >= 4 && buffer.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) return "elf";
const magic = buffer.subarray(0, 4).toString("hex");
if (["feedface", "feedfacf", "cefaedfe", "cffaedfe", "cafebabe", "bebafeca"].includes(magic)) return "mach-o";
if (buffer.length >= 2 && buffer.subarray(0, 2).toString("ascii") === "MZ") return "pe";
return null;
}
function readElf(file) {
const header = spawnSync("readelf", ["-h", file], { encoding: "utf8" });
const dynamic = spawnSync("readelf", ["-d", file], { encoding: "utf8" });
return {
machine: header.status === 0 ? header.stdout.match(/^\s*Machine:\s*(.+)$/m)?.[1]?.trim() ?? null : null,
needed: dynamic.status === 0
? [...dynamic.stdout.matchAll(/\(NEEDED\).*\[([^\]]+)\]/g)].map((match) => match[1]).sort()
: [],
};
}
async function walkBinaries(root) {
const result = [];
async function walk(directory) {
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) {
await walk(file);
} else if (entry.isFile()) {
const handle = await readFile(file);
const kind = binaryKind(handle);
if (!kind) continue;
const metadata = await stat(file);
result.push({
path: path.relative(root, file),
kind,
mode: metadata.mode & 0o777,
executable: (metadata.mode & 0o111) !== 0,
...(kind === "elf" ? readElf(file) : {}),
});
}
}
}
if (await stat(root).catch(() => null)) await walk(root);
return result;
}
const args = parseArgs(process.argv.slice(2));
const resources = path.join(args.app, "Contents", "Resources");
const asarPath = path.join(resources, "app.asar");
const unpackedPath = path.join(resources, "app.asar.unpacked");
const frameworkPlist = path.join(
args.app,
"Contents",
"Frameworks",
"Electron Framework.framework",
"Versions",
"A",
"Resources",
"Info.plist",
);
const appPlist = path.join(args.app, "Contents", "Info.plist");
const work = await mkdtemp(path.join(tmpdir(), "nani-upstream-analysis-"));
try {
extractAll(asarPath, work);
const packageJson = JSON.parse(await readFile(path.join(work, "package.json"), "utf8"));
const mainPath = path.join(work, packageJson.main);
const mainSource = await readFile(mainPath, "utf8");
const rendererAssets = path.join(work, "out", "renderer", "assets");
const rendererSource = (await readdir(rendererAssets))
.filter((file) => file.endsWith(".js"))
.map((file) => readFile(path.join(rendererAssets, file), "utf8"));
const rendererBundle = (await Promise.all(rendererSource)).join("\n");
const electronPlistSource = await readFile(frameworkPlist, "utf8");
const appPlistSource = await readFile(appPlist, "utf8");
const packages = {};
for (const name of ["better-sqlite3", "@napi-rs/system-ocr", "@nut-tree-fork/libnut-linux"]) {
const metadata = path.join(work, "node_modules", ...name.split("/"), "package.json");
const parsed = JSON.parse(await readFile(metadata, "utf8").catch(() => "null"));
packages[name] = parsed?.version ?? null;
}
const report = {
schemaVersion: 1,
app: {
productName: packageJson.productName ?? null,
version: packageJson.version ?? plistValue(appPlistSource, "CFBundleShortVersionString"),
bundleIdentifier: plistValue(appPlistSource, "CFBundleIdentifier"),
main: packageJson.main,
},
electron: {
version: plistValue(electronPlistSource, "CFBundleVersion"),
},
packages,
contracts: {
ocrTopLevelRequire: countMatches(mainSource, /require\(["']@napi-rs\/system-ocr["']\)/),
updaterEnablePredicate: countMatches(mainSource, /process\.platform!==["']win32["']/),
trayWin32Gate: countMatches(mainSource, /if\(![A-Za-z_$][\w$]*\(\)&&process\.platform===["']win32["']\)/),
loginItemGetter: countMatches(mainSource, /getLoginItemSettings\(\)\.openAtLogin/),
loginItemSetter: countMatches(mainSource, /setLoginItemSettings\(\{openAtLogin:/),
linuxOnboardingFallback: countMatches(
rendererBundle,
/window\.constants\.os===["']win32["']\?[^:]+welcomeToWinApp[^:]+:[^;]+welcomeToMacApp/,
),
protocolRegistration: countMatches(mainSource, /setAsDefaultProtocolClient/),
updateIpcKeys: Object.fromEntries(
["checkForUpdates", "checkForUpdatesSilent", "ensureUpdateDownloaded", "quitAndInstall"]
.map((key) => [key, countMatches(mainSource, new RegExp(`${key}:`))]),
),
},
binaries: await walkBinaries(unpackedPath),
...(args.dmg ? { dmg: { path: args.dmg, sha512: await sha512(args.dmg) } } : {}),
};
const serialized = `${JSON.stringify(report, null, 2)}\n`;
if (args.output) await writeFile(args.output, serialized);
else process.stdout.write(serialized);
} finally {
await rm(work, { recursive: true, force: true });
}
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/lib/common.sh
source "$SCRIPT_DIR/lib/common.sh"
require_linux_x64
for command in curl node unzip tar; do
require_command "$command"
done
seven_zip_command >/dev/null
report_dir="${NANI_REPORT_DIR:-$NANI_ROOT/dist/reports}"
mkdir -p "$NANI_BUILD_DIR" "$report_dir" "$(dirname "$NANI_OUTPUT_DIR")"
lock_dir="${NANI_OUTPUT_DIR}.lock"
mkdir "$lock_dir" 2>/dev/null || die "another build appears to be running: $lock_dir"
work_dir="$(mktemp -d "$NANI_BUILD_DIR/nani-build.XXXXXX")"
candidate="${NANI_OUTPUT_DIR}.candidate.$$"
backup="${NANI_OUTPUT_DIR}.previous.$$"
cleanup() {
status=$?
rm -rf -- "$candidate" "$backup" "$lock_dir"
if [ "${NANI_KEEP_WORKDIR:-0}" = 1 ]; then
log "keeping work directory: $work_dir"
else
rm -rf -- "$work_dir"
fi
exit "$status"
}
trap cleanup EXIT INT TERM
auto_patch_candidate() {
[ "${NANI_AUTOPATCHELF:-0}" = 1 ] || return 0
require_command auto-patchelf
local search_path="${NANI_AUTOPATCHELF_LIBRARY_PATH:-${LD_LIBRARY_PATH:-}}"
[ -n "$search_path" ] || die "NANI_AUTOPATCHELF requires a library search path"
local library_paths=()
IFS=: read -r -a library_paths <<<"$search_path"
local arguments=(--preserve-origin --paths "$candidate" --libs "${library_paths[@]}")
if [ -n "${NANI_AUTOPATCHELF_APPEND_RPATHS:-}" ]; then
local append_paths=()
IFS=: read -r -a append_paths <<<"$NANI_AUTOPATCHELF_APPEND_RPATHS"
arguments+=(--append-rpaths "${append_paths[@]}")
fi
auto-patchelf "${arguments[@]}"
}
printf '{"status":"not-run"}\n' >"$report_dir/patch-report.json"
log "resolving upstream release"
IFS=$'\t' read -r version source_url source_sha512 dmg_path < <("$SCRIPT_DIR/fetch-upstream.sh")
[ -n "$version" ] && [ -f "$dmg_path" ] || die "fetch-upstream returned incomplete data"
node "$SCRIPT_DIR/lib/build-metadata.mjs" write-upstream-report \
"$report_dir/upstream.json" "$version" "$source_url" "$source_sha512" "$dmg_path"
log "extracting Nani.app"
app_path="$("$SCRIPT_DIR/extract-dmg.sh" "$dmg_path" "$work_dir/dmg")"
upstream_resources="$app_path/Contents/Resources"
upstream_asar="$upstream_resources/app.asar"
[ -f "$upstream_asar" ] || die "upstream app.asar was not found"
electron_version="$(node "$SCRIPT_DIR/lib/build-metadata.mjs" electron-version "$app_path")"
electron_filename="electron-v${electron_version}-linux-x64.zip"
electron_base="https://github.com/electron/electron/releases/download/v${electron_version}"
electron_cache="$NANI_CACHE_DIR/electron/$electron_version"
mkdir -p "$electron_cache"
if [ -n "${NANI_ELECTRON_ZIP_PATH:-}" ]; then
[ -f "$NANI_ELECTRON_ZIP_PATH" ] || \
die "NANI_ELECTRON_ZIP_PATH does not exist: $NANI_ELECTRON_ZIP_PATH"
electron_zip="$NANI_ELECTRON_ZIP_PATH"
else
download_file "$electron_base/SHASUMS256.txt" "$electron_cache/SHASUMS256.txt"
electron_sha256="$(awk -v wanted="$electron_filename" '{ name=$2; sub(/^\*/, "", name); if (name == wanted) print $1 }' "$electron_cache/SHASUMS256.txt")"
[ "$(printf '%s\n' "$electron_sha256" | grep -Ec '^[0-9a-f]{64}$')" -eq 1 ] || \
die "could not find a unique SHA-256 for $electron_filename"
electron_zip="$electron_cache/$electron_filename"
download_file "$electron_base/$electron_filename" "$electron_zip" "$electron_sha256"
fi
mkdir -p "$candidate"
unzip -q "$electron_zip" -d "$candidate"
[ -x "$candidate/electron" ] || die "Electron archive did not contain the electron executable"
rm -f -- "$candidate/resources/default_app.asar"
cp -a -- "$upstream_asar" "$candidate/resources/app.asar"
if [ -d "$upstream_resources/app.asar.unpacked" ]; then
cp -a -- "$upstream_resources/app.asar.unpacked" "$candidate/resources/app.asar.unpacked"
else
mkdir -p "$candidate/resources/app.asar.unpacked"
fi
icon_source="$upstream_resources/app.asar.unpacked/resources/icon.png"
[ -f "$icon_source" ] || die "upstream app has no resources/icon.png"
cp -a -- "$icon_source" "$candidate/resources/icon.png"
[ -f "$SCRIPT_DIR/patch-asar.mjs" ] || die "missing scripts/patch-asar.mjs"
sqlite_version="$(node "$SCRIPT_DIR/lib/build-metadata.mjs" sqlite-version "$candidate/resources/app.asar")"
auto_patch_candidate
electron_abi="$(ELECTRON_RUN_AS_NODE=1 "$candidate/electron" -p 'process.versions.modules')"
case "$electron_abi" in
''|*[!0-9]*) die "could not determine Electron Node ABI" ;;
esac
if [ -n "${NANI_SQLITE_ARCHIVE_PATH:-}" ]; then
[ -f "$NANI_SQLITE_ARCHIVE_PATH" ] || \
die "NANI_SQLITE_ARCHIVE_PATH does not exist: $NANI_SQLITE_ARCHIVE_PATH"
sqlite_archive="$NANI_SQLITE_ARCHIVE_PATH"
else
sqlite_release="$NANI_CACHE_DIR/better-sqlite3/v${sqlite_version}/release.json"
mkdir -p "$(dirname "$sqlite_release")"
download_file "https://api.github.com/repos/WiseLibs/better-sqlite3/releases/tags/v${sqlite_version}" "$sqlite_release"
IFS=$'\t' read -r sqlite_url sqlite_sha256 sqlite_filename < <(
node "$SCRIPT_DIR/lib/build-metadata.mjs" sqlite-asset "$sqlite_release" "$sqlite_version" "$electron_abi"
)
[ -n "$sqlite_url" ] && [ -n "$sqlite_sha256" ] || die "could not resolve better-sqlite3 prebuild"
sqlite_archive="$(dirname "$sqlite_release")/$sqlite_filename"
download_file "$sqlite_url" "$sqlite_archive" "$sqlite_sha256"
fi
mkdir -p "$work_dir/sqlite"
tar -xzf "$sqlite_archive" -C "$work_dir/sqlite"
mapfile -d '' sqlite_sources < <(find "$work_dir/sqlite" -type f -name better_sqlite3.node -print0)
[ "${#sqlite_sources[@]}" -eq 1 ] || die "expected one better_sqlite3.node in prebuild"
native_replacements="$work_dir/native-replacements"
sqlite_replacement="$native_replacements/node_modules/better-sqlite3/build/Release/better_sqlite3.node"
mkdir -p "$(dirname "$sqlite_replacement")"
cp -a -- "${sqlite_sources[0]}" "$sqlite_replacement"
log "patching upstream ASAR"
node "$SCRIPT_DIR/patch-asar.mjs" \
--asar "$candidate/resources/app.asar" \
--unpacked "$candidate/resources/app.asar.unpacked" \
--report "$report_dir/patch-report.json" \
--native-replacements "$native_replacements"
cp -a -- "$report_dir/patch-report.json" "$candidate/patch-report.json"
auto_patch_candidate
node "$SCRIPT_DIR/lib/build-metadata.mjs" check-native "$candidate/resources/app.asar.unpacked"
if [ -f "$NANI_ROOT/runtime/start.sh.template" ]; then
cp -a -- "$NANI_ROOT/runtime/start.sh.template" "$candidate/start.sh"
chmod +x "$candidate/start.sh"
fi
write_build_info "$candidate/build-info.env" "$version" "$electron_version" "$source_url" "$source_sha512"
cp -a -- "$candidate/build-info.env" "$report_dir/build-info.env"
if [ -e "$NANI_OUTPUT_DIR" ]; then
mv -- "$NANI_OUTPUT_DIR" "$backup"
fi
if ! mv -- "$candidate" "$NANI_OUTPUT_DIR"; then
[ ! -e "$backup" ] || mv -- "$backup" "$NANI_OUTPUT_DIR"
die "failed to promote build candidate"
fi
rm -rf -- "$backup"
log "built Nani $version with Electron $electron_version at $NANI_OUTPUT_DIR"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/lib/common.sh
source "$SCRIPT_DIR/lib/common.sh"
require_linux_x64
app_source="${1:-$NANI_OUTPUT_DIR}"
[ -d "$app_source" ] || die "application directory does not exist: $app_source"
for required in \
electron \
resources/app.asar \
resources/icon.png \
patch-report.json \
build-info.env; do
[ -e "$app_source/$required" ] || die "application artifact is missing: $required"
done
[ -x "$app_source/electron" ] || die "Electron executable is not executable"
appimagetool="${APPIMAGETOOL:-appimagetool}"
require_command "$appimagetool"
version="$(sed -n 's/^version=//p' "$app_source/build-info.env" | head -n 1)"
[ -n "$version" ] || die "build-info.env does not contain a version"
output="${2:-$NANI_ROOT/dist/Nani-${version}-x86_64.AppImage}"
mkdir -p "$(dirname "$output")" "$NANI_BUILD_DIR"
output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")"
work_dir="$(mktemp -d "$NANI_BUILD_DIR/appimage.XXXXXX")"
trap 'rm -rf -- "$work_dir"' EXIT
app_dir="$work_dir/Nani.AppDir"
runtime_dir="$app_dir/usr/lib/nani"
mkdir -p \
"$runtime_dir" \
"$app_dir/usr/bin" \
"$app_dir/usr/share/applications" \
"$app_dir/usr/share/icons/hicolor/512x512/apps"
cp -a "$app_source/." "$runtime_dir/"
install -m 0755 "$NANI_ROOT/runtime/start.sh.template" "$runtime_dir/nani"
install -m 0644 "$NANI_ROOT/runtime/nani.desktop.template" \
"$app_dir/usr/share/applications/nani.desktop"
install -m 0644 "$app_source/resources/icon.png" \
"$app_dir/usr/share/icons/hicolor/512x512/apps/nani.png"
ln -s usr/lib/nani/nani "$app_dir/AppRun"
ln -s ../lib/nani/nani "$app_dir/usr/bin/nani"
ln -s usr/share/applications/nani.desktop "$app_dir/nani.desktop"
ln -s usr/share/icons/hicolor/512x512/apps/nani.png "$app_dir/nani.png"
log "building Nani $version AppImage"
ARCH=x86_64 VERSION="$version" "$appimagetool" "$app_dir" "$output"
log "created $output"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/lib/common.sh
source "$SCRIPT_DIR/lib/common.sh"
[ "$#" -eq 2 ] || die "usage: extract-dmg.sh DMG DESTINATION"
dmg_path="$1"
destination="$2"
[ -f "$dmg_path" ] || die "DMG does not exist: $dmg_path"
seven_zip="$(seven_zip_command)"
mkdir -p "$destination"
if ! "$seven_zip" x -y "-o$destination" "$dmg_path" >/dev/null; then
log "7-Zip reported a DMG/HFS warning; validating extracted contents"
fi
app_path="$(find "$destination" -type d -name Nani.app -print -quit)"
[ -n "$app_path" ] || die "Nani.app was not found in the DMG"
[ -f "$app_path/Contents/Info.plist" ] || die "extracted Nani.app is incomplete"
[ -f "$app_path/Contents/Resources/app.asar" ] || die "extracted Nani.app has no app.asar"
printf '%s\n' "$app_path"
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/lib/common.sh
source "$SCRIPT_DIR/lib/common.sh"
require_linux_x64
require_command node
if [ -n "${NANI_DMG_PATH:-}" ]; then
[ -f "$NANI_DMG_PATH" ] || die "NANI_DMG_PATH does not exist: $NANI_DMG_PATH"
dmg_path="$(cd "$(dirname "$NANI_DMG_PATH")" && pwd)/$(basename "$NANI_DMG_PATH")"
actual_sha512="$(sha512_base64 "$dmg_path")"
if [ -n "${NANI_DMG_SHA512:-}" ] && [ "$actual_sha512" != "$NANI_DMG_SHA512" ]; then
die "SHA-512 mismatch for local DMG"
fi
printf '%s\t%s\t%s\t%s\n' "${NANI_VERSION:-local}" "file://$dmg_path" "$actual_sha512" "$dmg_path"
exit 0
fi
mkdir -p "$NANI_CACHE_DIR/manifests" "$NANI_CACHE_DIR/dmg"
manifest_path="$NANI_CACHE_DIR/manifests/latest-mac.yml"
manifest_part="${manifest_path}.part"
require_command curl
rm -f -- "$manifest_part"
if ! curl --fail --location --retry 3 --retry-delay 1 --output "$manifest_part" "$NANI_MANIFEST_URL"; then
rm -f -- "$manifest_part"
die "failed to download upstream manifest"
fi
mv -f -- "$manifest_part" "$manifest_path"
IFS=$'\t' read -r version dmg_url expected_sha512 filename < <(
node "$SCRIPT_DIR/lib/resolve-upstream.mjs" "$manifest_path" "$NANI_MANIFEST_URL"
)
[ -n "$version" ] && [ -n "$dmg_url" ] && [ -n "$expected_sha512" ] && [ -n "$filename" ] || \
die "upstream manifest resolver returned incomplete data"
cache_key="$(printf '%s' "${expected_sha512:0:16}" | tr '/+' '_-')"
dmg_path="$NANI_CACHE_DIR/dmg/${version}-${cache_key}-$filename"
if [ -f "$dmg_path" ] && [ "$(sha512_base64 "$dmg_path")" = "$expected_sha512" ]; then
log "using cached $filename"
else
rm -f -- "$dmg_path" "${dmg_path}.part"
log "downloading Nani $version"
if ! curl --fail --location --retry 3 --retry-delay 1 --output "${dmg_path}.part" "$dmg_url"; then
rm -f -- "${dmg_path}.part"
die "failed to download upstream DMG"
fi
if [ "$(sha512_base64 "${dmg_path}.part")" != "$expected_sha512" ]; then
rm -f -- "${dmg_path}.part"
die "SHA-512 mismatch for upstream DMG"
fi
mv -f -- "${dmg_path}.part" "$dmg_path"
fi
printf '%s\t%s\t%s\t%s\n' "$version" "$dmg_url" "$expected_sha512" "$dmg_path"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/lib/common.sh
source "$SCRIPT_DIR/lib/common.sh"
desktop_template="$NANI_ROOT/runtime/nani.desktop.template"
launcher="$NANI_OUTPUT_DIR/start.sh"
icon="$NANI_OUTPUT_DIR/resources/icon.png"
data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
bin_home="${NANI_BIN_DIR:-$HOME/.local/bin}"
desktop_dir="$data_home/applications"
icon_dir="$data_home/icons/hicolor/512x512/apps"
desktop_file="$desktop_dir/nani.desktop"
bin_link="$bin_home/nani"
[ -f "$desktop_template" ] || die "desktop template not found: $desktop_template"
[ -x "$launcher" ] || die "launcher not found; run make bootstrap first"
[ -f "$icon" ] || die "application icon not found; run make bootstrap first"
require_command update-desktop-database
case "$bin_link" in
*[[:space:]]*) die "launcher path contains whitespace: $bin_link" ;;
esac
mkdir -p "$bin_home" "$desktop_dir" "$icon_dir"
ln -sfn -- "$launcher" "$bin_link"
install -m 0644 "$icon" "$icon_dir/nani.png"
desktop_part="${desktop_file}.part"
awk -v launcher="$bin_link" '
/^Exec=/ { print "Exec=" launcher " %U"; next }
{ print }
' "$desktop_template" >"$desktop_part"
chmod 0644 "$desktop_part"
mv -f -- "$desktop_part" "$desktop_file"
update-desktop-database "$desktop_dir"
if command -v xdg-mime >/dev/null 2>&1; then
mimeapps_file="${XDG_CONFIG_HOME:-$HOME/.config}/mimeapps.list"
if [ ! -e "$mimeapps_file" ] || [ -w "$(readlink -f -- "$mimeapps_file")" ]; then
xdg-mime default nani.desktop x-scheme-handler/naniapp
else
log "mimeapps.list is managed read-only; using the desktop MIME cache association"
fi
fi
log "installed desktop entry: $desktop_file"
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import * as asar from "@electron/asar";
function fail(message) {
console.error(`[nani] error: ${message}`);
process.exit(1);
}
function walk(directory) {
const found = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) found.push(...walk(entryPath));
else found.push(entryPath);
}
return found;
}
async function sha512(filePath) {
const hash = createHash("sha512");
await new Promise((resolve, reject) => {
fs.createReadStream(filePath).on("data", (chunk) => hash.update(chunk)).on("end", resolve).on("error", reject);
});
process.stdout.write(hash.digest("base64") + "\n");
}
function electronVersion(appPath) {
const candidates = walk(path.join(appPath, "Contents", "Frameworks")).filter(
(file) => path.basename(file) === "Info.plist" && file.includes("Electron Framework.framework"),
);
for (const candidate of candidates) {
const plist = fs.readFileSync(candidate, "utf8");
const match = plist.match(
/<key>CFBundle(?:ShortVersionString|Version)<\/key>\s*<string>([^<]+)<\/string>/,
);
if (match) return match[1];
}
fail("could not detect Electron version from Electron Framework Info.plist");
}
function sqliteVersion(asarPath) {
try {
const packageJson = JSON.parse(
asar.extractFile(asarPath, "node_modules/better-sqlite3/package.json").toString("utf8"),
);
if (typeof packageJson.version === "string" && packageJson.version) return packageJson.version;
} catch (error) {
fail(`could not inspect better-sqlite3 in ASAR: ${error.message}`);
}
fail("better-sqlite3 package has no version");
}
function sqliteAsset(releasePath, version, abi) {
const release = JSON.parse(fs.readFileSync(releasePath, "utf8"));
const expectedName = `better-sqlite3-v${version}-electron-v${abi}-linux-x64.tar.gz`;
const matches = (release.assets ?? []).filter((asset) => asset.name === expectedName);
if (matches.length !== 1) fail(`expected one ${expectedName} release asset, found ${matches.length}`);
const asset = matches[0];
const digest = typeof asset.digest === "string" ? asset.digest : "";
if (!digest.startsWith("sha256:")) fail(`GitHub did not provide a SHA-256 digest for ${expectedName}`);
if (typeof asset.browser_download_url !== "string" || !asset.browser_download_url.startsWith("https://")) {
fail(`release asset has no HTTPS download URL: ${expectedName}`);
}
process.stdout.write([asset.browser_download_url, digest.slice(7), expectedName].join("\t") + "\n");
}
function writeUpstreamReport(output, version, url, sha512Value, dmgPath) {
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(
output,
JSON.stringify({ version, url, sha512: sha512Value, dmgPath }, null, 2) + "\n",
);
}
function checkNativePayload(root) {
const violations = [];
const machoMagics = new Set(["cafebabe", "bebafeca", "feedface", "cefaedfe", "feedfacf", "cffaedfe"]);
for (const file of walk(root)) {
const descriptor = fs.openSync(file, "r");
const header = Buffer.alloc(20);
const length = fs.readSync(descriptor, header, 0, header.length, 0);
fs.closeSync(descriptor);
if (length < 4) continue;
const magic = header.subarray(0, 4).toString("hex");
if (machoMagics.has(magic) || magic.startsWith("4d5a")) {
violations.push(`${file}: non-Linux executable`);
continue;
}
if (file.endsWith(".node") || file.endsWith(".so")) {
const isElfX64 =
magic === "7f454c46" && header[4] === 2 && header[5] === 1 && header[18] === 0x3e && header[19] === 0;
if (!isElfX64) violations.push(`${file}: native module is not x86_64 ELF`);
}
}
if (violations.length) fail(`native payload validation failed:\n${violations.join("\n")}`);
}
const [command, ...args] = process.argv.slice(2);
switch (command) {
case "sha512":
if (args.length !== 1) fail("usage: build-metadata.mjs sha512 FILE");
await sha512(args[0]);
break;
case "electron-version":
if (args.length !== 1) fail("usage: build-metadata.mjs electron-version APP");
process.stdout.write(electronVersion(args[0]) + "\n");
break;
case "sqlite-version":
if (args.length !== 1) fail("usage: build-metadata.mjs sqlite-version ASAR");
process.stdout.write(sqliteVersion(args[0]) + "\n");
break;
case "sqlite-asset":
if (args.length !== 3) fail("usage: build-metadata.mjs sqlite-asset RELEASE_JSON VERSION ABI");
sqliteAsset(...args);
break;
case "write-upstream-report":
if (args.length !== 5) fail("usage: build-metadata.mjs write-upstream-report OUTPUT VERSION URL SHA512 DMG");
writeUpstreamReport(...args);
break;
case "check-native":
if (args.length !== 1) fail("usage: build-metadata.mjs check-native ROOT");
checkNativePayload(args[0]);
break;
default:
fail(`unknown command: ${command ?? ""}`);
}
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
NANI_ROOT="${NANI_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
NANI_CACHE_DIR="${NANI_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/nani-translate-linux}"
NANI_BUILD_DIR="${NANI_BUILD_DIR:-$NANI_ROOT/build}"
NANI_OUTPUT_DIR="${NANI_OUTPUT_DIR:-$NANI_ROOT/nani-app}"
NANI_MANIFEST_URL="${NANI_MANIFEST_URL:-https://nani-desktop.kiok.jp/artifacts/latest-mac.yml}"
log() {
printf '[nani] %s\n' "$*" >&2
}
die() {
printf '[nani] error: %s\n' "$*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
}
require_linux_x64() {
[ "$(uname -s)" = Linux ] || die "only Linux is supported"
case "$(uname -m)" in
x86_64|amd64) ;;
*) die "only x86_64 is supported" ;;
esac
}
seven_zip_command() {
if command -v 7zz >/dev/null 2>&1; then
printf '%s\n' 7zz
elif command -v 7z >/dev/null 2>&1; then
printf '%s\n' 7z
else
die "required command not found: 7zz or 7z"
fi
}
sha512_base64() {
require_command node
node "$NANI_ROOT/scripts/lib/build-metadata.mjs" sha512 "$1"
}
sha256_hex() {
require_command sha256sum
sha256sum "$1" | awk '{print $1}'
}
download_file() {
local url="$1"
local destination="$2"
local expected_sha256="${3:-}"
local part="${destination}.part"
case "$url" in
https://*) ;;
*) die "refusing non-HTTPS download: $url" ;;
esac
require_command curl
mkdir -p "$(dirname "$destination")"
if [ -f "$destination" ]; then
if [ -z "$expected_sha256" ] || [ "$(sha256_hex "$destination")" = "$expected_sha256" ]; then
log "using cached $(basename "$destination")"
return 0
fi
log "cached checksum mismatch; downloading again"
rm -f -- "$destination"
fi
rm -f -- "$part"
if ! curl --fail --location --retry 3 --retry-delay 1 --output "$part" "$url"; then
rm -f -- "$part"
die "download failed: $url"
fi
if [ -n "$expected_sha256" ] && [ "$(sha256_hex "$part")" != "$expected_sha256" ]; then
rm -f -- "$part"
die "SHA-256 mismatch for $(basename "$destination")"
fi
mv -f -- "$part" "$destination"
}
write_build_info() {
local destination="$1"
local version="$2"
local electron_version="$3"
local source_url="$4"
local source_sha512="$5"
{
printf 'version=%s\n' "$version"
printf 'electron_version=%s\n' "$electron_version"
printf 'source_url=%s\n' "$source_url"
printf 'source_sha512=%s\n' "$source_sha512"
} >"$destination"
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { parse } from "yaml";
function fail(message) {
console.error(`[nani] error: ${message}`);
process.exit(1);
}
const [manifestPath, manifestUrl] = process.argv.slice(2);
if (!manifestPath || !manifestUrl) {
fail("usage: resolve-upstream.mjs MANIFEST_PATH MANIFEST_URL");
}
let manifest;
try {
manifest = parse(fs.readFileSync(manifestPath, "utf8"));
} catch (error) {
fail(`could not parse upstream manifest: ${error.message}`);
}
const files = Array.isArray(manifest?.files) ? manifest.files : [];
const dmgs = files.filter(
(file) => typeof file?.url === "string" && file.url.toLowerCase().endsWith(".dmg"),
);
if (dmgs.length !== 1) {
fail(`expected exactly one DMG in manifest, found ${dmgs.length}`);
}
const selected = dmgs[0];
if (typeof manifest.version !== "string" || manifest.version.length === 0) {
fail("manifest has no version");
}
if (typeof selected.sha512 !== "string" || selected.sha512.length === 0) {
fail("DMG entry has no SHA-512");
}
let resolvedUrl;
try {
resolvedUrl = new URL(selected.url, manifestUrl).href;
} catch (error) {
fail(`invalid DMG URL: ${error.message}`);
}
if (!resolvedUrl.startsWith("https://")) {
fail("DMG URL must use HTTPS");
}
const filename = path.basename(new URL(resolvedUrl).pathname);
if (!filename) fail("DMG URL has no filename");
process.stdout.write(
[manifest.version, resolvedUrl, selected.sha512, filename].join("\t") + "\n",
);
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import {
cpSync,
chmodSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import * as asar from "@electron/asar";
import { addIntegrityFailure, runPatchDescriptors } from "./patches/engine.mjs";
import { corePatchDescriptors } from "./patches/index.mjs";
import { copyTreeContents, relativeFileHashes, sha256File, walkFiles } from "./patches/lib.mjs";
import { verifyLinuxOnboarding } from "./patches/linux-onboarding.mjs";
import { verifyOcrStub } from "./patches/ocr-stub.mjs";
import {
verifyNonLinuxHeaderEntries,
verifyNonLinuxPayloadsAbsent,
} from "./patches/prune-non-linux.mjs";
import { AUTOSTART_MARKER } from "./patches/xdg-autostart.mjs";
import { UPDATER_MARKER } from "./patches/disable-updater.mjs";
import { TRAY_MARKER } from "./patches/linux-tray.mjs";
const USAGE = `Usage:
node scripts/patch-asar.mjs \\
--asar PATH \\
--unpacked PATH \\
--report PATH \\
[--native-replacements DIRECTORY]
The ASAR and its unpacked directory are replaced in place only after every
required patch and post-repack integrity check succeeds. A native replacement
directory, when supplied, must mirror paths below the extracted application
(for example node_modules/better-sqlite3/build/Release/better_sqlite3.node).`;
export const XSEL_RELATIVE_PATH = "node_modules/clipboardy/fallbacks/linux/xsel";
function parseArgs(argv) {
const options = {};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--help" || argument === "-h") return { help: true };
const key = {
"--asar": "asarPath",
"--unpacked": "unpackedPath",
"--report": "reportPath",
"--native-replacements": "nativeReplacements",
}[argument];
if (!key) throw new Error(`Unknown argument: ${argument}`);
const value = argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${argument} requires a path`);
options[key] = path.resolve(value);
index += 1;
}
for (const key of ["asarPath", "unpackedPath", "reportPath"]) {
if (!options[key]) throw new Error(`Missing required option for ${key}`);
}
return options;
}
function atomicJson(filePath, value) {
mkdirSync(path.dirname(filePath), { recursive: true });
const temporary = `${filePath}.tmp-${process.pid}`;
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
renameSync(temporary, filePath);
}
function safeMainPath(extractedDir) {
const packagePath = path.join(extractedDir, "package.json");
const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
if (typeof packageJson.main !== "string" || packageJson.main.length === 0) {
throw new Error("Extracted package.json has no main entrypoint");
}
const resolved = path.resolve(extractedDir, packageJson.main);
const relative = path.relative(extractedDir, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Unsafe main entrypoint in package.json: ${packageJson.main}`);
}
if (!existsSync(resolved)) throw new Error(`Main bundle does not exist: ${packageJson.main}`);
return { mainBundlePath: resolved, packageJson };
}
function writeOrderingFile(extractedDir, orderingPath) {
const ordering = walkFiles(extractedDir)
.map((filePath) => path.relative(extractedDir, filePath).split(path.sep).join("/"))
.join("\n");
writeFileSync(orderingPath, `${ordering}\n`);
}
function sha256Text(value) {
return createHash("sha256").update(value).digest("hex");
}
function prepareExecutablePayloads(extractedDir) {
const relativePath = XSEL_RELATIVE_PATH;
const filePath = path.join(extractedDir, relativePath);
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
throw new Error(`Required Linux executable payload is missing: ${relativePath}`);
}
const beforeHash = sha256File(filePath);
const mode = statSync(filePath).mode & 0o777;
chmodSync(filePath, mode | 0o111);
if (sha256File(filePath) !== beforeHash) {
throw new Error(`Setting executable bits changed payload bytes: ${relativePath}`);
}
return {
[relativePath]: {
sha256: beforeHash,
mode: statSync(filePath).mode & 0o777,
},
};
}
function verifyExecutablePayloads(stagedUnpacked, verifyDir, executablePayloads) {
for (const [relativePath, expected] of Object.entries(executablePayloads)) {
const unpackedPath = path.join(stagedUnpacked, relativePath);
const extractedPath = path.join(verifyDir, relativePath);
for (const [location, filePath] of [
["app.asar.unpacked", unpackedPath],
["post-repack extraction", extractedPath],
]) {
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
throw new Error(`Executable payload is missing from ${location}: ${relativePath}`);
}
if (sha256File(filePath) !== expected.sha256) {
throw new Error(`Executable payload bytes changed in ${location}: ${relativePath}`);
}
if ((statSync(filePath).mode & 0o111) === 0) {
throw new Error(`Executable payload lost its executable bit in ${location}: ${relativePath}`);
}
}
}
}
function promoteOutputs({ stagedAsar, stagedUnpacked, asarPath, unpackedPath, workspace }) {
const previousAsar = path.join(workspace, "previous.asar");
const previousUnpacked = path.join(workspace, "previous.asar.unpacked");
let asarBackedUp = false;
let unpackedBackedUp = false;
let asarPromoted = false;
let unpackedPromoted = false;
try {
renameSync(asarPath, previousAsar);
asarBackedUp = true;
if (existsSync(unpackedPath)) {
renameSync(unpackedPath, previousUnpacked);
unpackedBackedUp = true;
}
renameSync(stagedAsar, asarPath);
asarPromoted = true;
if (existsSync(stagedUnpacked)) {
renameSync(stagedUnpacked, unpackedPath);
unpackedPromoted = true;
}
} catch (error) {
if (unpackedPromoted) rmSync(unpackedPath, { recursive: true, force: true });
if (asarPromoted) rmSync(asarPath, { force: true });
if (unpackedBackedUp) renameSync(previousUnpacked, unpackedPath);
if (asarBackedUp) renameSync(previousAsar, asarPath);
throw error;
}
}
async function postRepackVerification({
stagedAsar,
stagedUnpacked,
verifyDir,
expectedMainSource,
mainRelativePath,
nativeHashes,
executablePayloads,
}) {
verifyNonLinuxHeaderEntries(asar.listPackage(stagedAsar));
await asar.extractAll(stagedAsar, verifyDir);
if (existsSync(stagedUnpacked)) copyTreeContents(stagedUnpacked, verifyDir);
const verifiedMain = readFileSync(path.join(verifyDir, mainRelativePath), "utf8");
if (verifiedMain !== expectedMainSource) {
throw new Error("Main bundle bytes changed during ASAR repack");
}
for (const marker of [TRAY_MARKER, UPDATER_MARKER]) {
if (verifiedMain.split(marker).length !== 2) {
throw new Error(`Required patch marker is missing or duplicated: ${marker}`);
}
}
const optionalMarkerCount = verifiedMain.split(AUTOSTART_MARKER).length - 1;
if (optionalMarkerCount > 1) {
throw new Error(`Optional patch marker is duplicated: ${AUTOSTART_MARKER}`);
}
verifyOcrStub(verifyDir);
verifyLinuxOnboarding(verifyDir);
verifyNonLinuxPayloadsAbsent(verifyDir);
verifyExecutablePayloads(stagedUnpacked, verifyDir, executablePayloads);
for (const [relativePath, expectedHash] of Object.entries(nativeHashes)) {
const actualPath = path.join(verifyDir, relativePath);
if (!existsSync(actualPath) || sha256File(actualPath) !== expectedHash) {
throw new Error(`Native replacement failed integrity check: ${relativePath}`);
}
}
}
async function patchAsar(options) {
if (!existsSync(options.asarPath)) throw new Error(`ASAR does not exist: ${options.asarPath}`);
if (options.nativeReplacements && !existsSync(options.nativeReplacements)) {
throw new Error(`Native replacements do not exist: ${options.nativeReplacements}`);
}
// Keep staging beside the target so final rename operations stay on one filesystem.
const workspace = mkdtempSync(path.join(path.dirname(options.asarPath), ".nani-patch-asar-"));
const extractedDir = path.join(workspace, "extracted");
const verifyDir = path.join(workspace, "verify");
const orderingPath = path.join(workspace, "app.asar.ordering");
const stagedAsar = path.join(workspace, "app.asar");
const stagedUnpacked = `${stagedAsar}.unpacked`;
const report = {
schemaVersion: 1,
input: {
asar: options.asarPath,
unpacked: options.unpackedPath,
sha256: sha256File(options.asarPath),
},
patches: [],
success: false,
};
try {
await asar.extractAll(options.asarPath, extractedDir);
if (existsSync(options.unpackedPath)) copyTreeContents(options.unpackedPath, extractedDir);
if (options.nativeReplacements) copyTreeContents(options.nativeReplacements, extractedDir);
const nativeHashes = options.nativeReplacements
? relativeFileHashes(options.nativeReplacements)
: {};
const executablePayloads = prepareExecutablePayloads(extractedDir);
const { mainBundlePath, packageJson } = safeMainPath(extractedDir);
report.upstream = {
name: packageJson.name ?? null,
version: packageJson.version ?? null,
main: path.relative(extractedDir, mainBundlePath).split(path.sep).join("/"),
};
const result = runPatchDescriptors({
extractedDir,
mainBundlePath,
descriptors: corePatchDescriptors,
});
report.patches.push(...result.entries);
if (result.hasRequiredFailure || result.hasIntegrityFailure) {
throw new Error("One or more required ASAR patches failed");
}
writeOrderingFile(extractedDir, orderingPath);
await asar.createPackageWithOptions(extractedDir, stagedAsar, {
ordering: orderingPath,
unpack: "{*.node,*.so,xsel}",
});
await postRepackVerification({
stagedAsar,
stagedUnpacked,
verifyDir,
expectedMainSource: result.mainSource,
mainRelativePath: report.upstream.main,
nativeHashes,
executablePayloads,
});
promoteOutputs({
stagedAsar,
stagedUnpacked,
asarPath: options.asarPath,
unpackedPath: options.unpackedPath,
workspace,
});
report.output = {
sha256: sha256File(options.asarPath),
mainSha256: sha256Text(result.mainSource),
nativeReplacements: Object.keys(nativeHashes).length,
executablePayloads,
};
report.success = true;
atomicJson(options.reportPath, report);
return report;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
if (!report.patches.some((entry) => entry.status === "failed-integrity") &&
!report.patches.some((entry) => entry.status === "failed-required")) {
addIntegrityFailure(report.patches, "patch-asar", detail);
}
report.error = detail;
atomicJson(options.reportPath, report);
throw error;
} finally {
rmSync(workspace, { recursive: true, force: true });
}
}
async function main() {
let options;
try {
options = parseArgs(process.argv.slice(2));
} catch (error) {
console.error(error.message);
console.error(USAGE);
process.exitCode = 2;
return;
}
if (options.help) {
console.log(USAGE);
return;
}
try {
const report = await patchAsar(options);
const summary = report.patches.map(({ id, status }) => `${id}=${status}`).join(", ");
console.log(`ASAR patched: ${summary}`);
} catch (error) {
console.error(`ASAR patch failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
}
export {
parseArgs,
patchAsar,
postRepackVerification,
prepareExecutablePayloads,
promoteOutputs,
safeMainPath,
writeOrderingFile,
verifyExecutablePayloads,
};
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) await main();
+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" };
},
});