Initial commit
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Test and lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install Node.js dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Check JavaScript syntax
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Check shell scripts
|
||||||
|
run: shellcheck -x scripts/*.sh scripts/lib/*.sh runtime/start.sh.template
|
||||||
|
|
||||||
|
- name: Check Bash syntax
|
||||||
|
run: bash -n scripts/*.sh scripts/lib/*.sh runtime/start.sh.template
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: cachix/install-nix-action@b4b293eae0b79aac8a161bb32925a5508c9cca93 # v31
|
||||||
|
|
||||||
|
- name: Check Nix flake
|
||||||
|
run: nix flake check
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
name: Upstream watchdog
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "23 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: upstream-watchdog
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify-upstream:
|
||||||
|
name: Verify upstream compatibility
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
build_status: ${{ steps.verify.outputs.build_status }}
|
||||||
|
drift: ${{ steps.verify.outputs.drift }}
|
||||||
|
new_version: ${{ steps.verify.outputs.new_version }}
|
||||||
|
upstream_version: ${{ steps.verify.outputs.upstream_version }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install Node.js dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install --yes p7zip-full
|
||||||
|
|
||||||
|
- name: Verify current upstream release
|
||||||
|
id: verify
|
||||||
|
continue-on-error: true
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set +e
|
||||||
|
bash scripts/build-app.sh >watchdog-build.log 2>&1
|
||||||
|
build_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "build_status=$build_status" >>"$GITHUB_OUTPUT"
|
||||||
|
baseline_version="$(sed -n "s/^- Nani \`\\([^\`]*\\)\`.*/\\1/p" docs/upstream-analysis.md | head -n 1)"
|
||||||
|
upstream_version=""
|
||||||
|
upstream_report=dist/reports/upstream.json
|
||||||
|
if [ -f "$upstream_report" ]; then
|
||||||
|
upstream_version="$(node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).version' "$upstream_report")"
|
||||||
|
fi
|
||||||
|
echo "upstream_version=$upstream_version" >>"$GITHUB_OUTPUT"
|
||||||
|
if [ -n "$baseline_version" ] && [ -n "$upstream_version" ] && \
|
||||||
|
[ "$baseline_version" != "$upstream_version" ]; then
|
||||||
|
echo "new_version=true" >>"$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "new_version=false" >>"$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
drift=false
|
||||||
|
report=dist/reports/patch-report.json
|
||||||
|
if [ "$build_status" -ne 0 ] && [ -f "$report" ] && node - "$report" <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const report = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||||
|
const drift = Array.isArray(report.patches) && report.patches.some(
|
||||||
|
({ status }) => status === "failed-required" || status === "failed-integrity",
|
||||||
|
);
|
||||||
|
process.exit(drift ? 0 : 1);
|
||||||
|
NODE
|
||||||
|
then
|
||||||
|
drift=true
|
||||||
|
fi
|
||||||
|
echo "drift=$drift" >>"$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
exit "$build_status"
|
||||||
|
|
||||||
|
- name: Fail when upstream verification failed
|
||||||
|
if: steps.verify.outputs.build_status != '0'
|
||||||
|
run: exit 1
|
||||||
|
|
||||||
|
report:
|
||||||
|
name: Report upstream changes
|
||||||
|
needs: verify-upstream
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
(needs.verify-upstream.outputs.new_version == 'true' ||
|
||||||
|
needs.verify-upstream.outputs.drift == 'true')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
issues: write # Required only for creating or updating watchdog issues.
|
||||||
|
steps:
|
||||||
|
- name: Report a new upstream version
|
||||||
|
if: needs.verify-upstream.outputs.new_version == 'true'
|
||||||
|
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||||
|
env:
|
||||||
|
BUILD_STATUS: ${{ needs.verify-upstream.outputs.build_status }}
|
||||||
|
UPSTREAM_VERSION: ${{ needs.verify-upstream.outputs.upstream_version }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const title = `[watchdog] Nani ${process.env.UPSTREAM_VERSION} is available`;
|
||||||
|
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
state: "all",
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const alreadyReported = issues.some(
|
||||||
|
(issue) => !issue.pull_request && issue.title === title,
|
||||||
|
);
|
||||||
|
if (!alreadyReported) {
|
||||||
|
const result = process.env.BUILD_STATUS === "0" ? "passed" : "failed";
|
||||||
|
await github.rest.issues.create({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
title,
|
||||||
|
body: [
|
||||||
|
`Nani ${process.env.UPSTREAM_VERSION} differs from the documented baseline.`,
|
||||||
|
`The Linux compatibility build **${result}**.`,
|
||||||
|
"",
|
||||||
|
`Workflow run: https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Create or update drift issue
|
||||||
|
if: needs.verify-upstream.outputs.drift == 'true'
|
||||||
|
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const title = "[watchdog] Upstream patch drift detected";
|
||||||
|
const body = [
|
||||||
|
"The upstream compatibility build detected required patch drift.",
|
||||||
|
"",
|
||||||
|
`Workflow run: https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||||
|
].join("\n");
|
||||||
|
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
state: "open",
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
const existing = openIssues.find(
|
||||||
|
(issue) => !issue.pull_request && issue.title === title,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: existing.number,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.create({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
nani-app/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.cache/
|
||||||
|
*.dmg
|
||||||
|
*.part
|
||||||
|
patch-report.json
|
||||||
|
upstream-analysis.json
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
SHELL := /usr/bin/env bash
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help deps bootstrap fetch analyze build-app run update appimage install-desktop check-shell test clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@printf '%s\n' \
|
||||||
|
'make bootstrap Install Node dependencies and build nani-app' \
|
||||||
|
'make analyze Analyze the current upstream DMG' \
|
||||||
|
'make build-app Build nani-app from the latest official DMG' \
|
||||||
|
'make run Run an existing nani-app build' \
|
||||||
|
'make update Rebuild from the current upstream release' \
|
||||||
|
'make appimage Build an AppImage from nani-app' \
|
||||||
|
'make install-desktop Install the desktop entry for this user' \
|
||||||
|
'make test Run syntax and project tests'
|
||||||
|
|
||||||
|
deps:
|
||||||
|
@if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
|
||||||
|
bootstrap: deps build-app
|
||||||
|
|
||||||
|
fetch: deps
|
||||||
|
@./scripts/fetch-upstream.sh
|
||||||
|
|
||||||
|
analyze: deps
|
||||||
|
@test -f scripts/analyze-upstream.mjs || { echo 'missing scripts/analyze-upstream.mjs' >&2; exit 1; }
|
||||||
|
@set -euo pipefail; \
|
||||||
|
mkdir -p build dist/reports; \
|
||||||
|
work="$$(mktemp -d build/nani-analysis.XXXXXX)"; \
|
||||||
|
trap 'rm -rf -- "$$work"' EXIT; \
|
||||||
|
IFS=$$'\t' read -r version url sha dmg < <(./scripts/fetch-upstream.sh); \
|
||||||
|
app="$$(./scripts/extract-dmg.sh "$$dmg" "$$work/dmg")"; \
|
||||||
|
node scripts/analyze-upstream.mjs --app "$$app" --dmg "$$dmg" --output dist/reports/upstream-analysis.json; \
|
||||||
|
printf 'analyzed Nani %s: %s\n' "$$version" dist/reports/upstream-analysis.json
|
||||||
|
|
||||||
|
build-app:
|
||||||
|
@./scripts/build-app.sh
|
||||||
|
|
||||||
|
run:
|
||||||
|
@test -x nani-app/start.sh || { echo 'run make bootstrap first' >&2; exit 1; }
|
||||||
|
@./nani-app/start.sh
|
||||||
|
|
||||||
|
update: deps build-app
|
||||||
|
|
||||||
|
appimage:
|
||||||
|
@test -x scripts/build-appimage.sh || { echo 'missing scripts/build-appimage.sh' >&2; exit 1; }
|
||||||
|
@./scripts/build-appimage.sh
|
||||||
|
|
||||||
|
install-desktop:
|
||||||
|
@test -x scripts/install-desktop.sh || { echo 'missing scripts/install-desktop.sh' >&2; exit 1; }
|
||||||
|
@./scripts/install-desktop.sh
|
||||||
|
|
||||||
|
check-shell:
|
||||||
|
@bash -n install.sh scripts/*.sh scripts/lib/*.sh
|
||||||
|
|
||||||
|
test: check-shell
|
||||||
|
@npm test
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@rm -rf -- build nani-app
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Third-party notices
|
||||||
|
|
||||||
|
Parts of the build, patch-report, launcher, and packaging design are adapted
|
||||||
|
from [ilysenko/codex-desktop-linux](https://github.com/ilysenko/codex-desktop-linux)
|
||||||
|
at commit `9825ca69e486ca9e64922190b6ec8ecbf623dbf2`.
|
||||||
|
|
||||||
|
Copyright (c) 2025 ilysenko
|
||||||
|
|
||||||
|
The referenced project is licensed under the MIT License:
|
||||||
|
|
||||||
|
> Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
> of this software and associated documentation files (the "Software"), to deal
|
||||||
|
> in the Software without restriction, including without limitation the rights
|
||||||
|
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
> copies of the Software, and to permit persons to whom the Software is
|
||||||
|
> furnished to do so, subject to the following conditions:
|
||||||
|
>
|
||||||
|
> The above copyright notice and this permission notice shall be included in all
|
||||||
|
> copies or substantial portions of the Software.
|
||||||
|
>
|
||||||
|
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
> SOFTWARE.
|
||||||
|
|
||||||
|
This notice covers only code and design adapted from that project. Nani,
|
||||||
|
official Nani application files, artwork, and trademarks remain the property
|
||||||
|
of Kioku LLC and its licensors.
|
||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785454630,
|
||||||
|
"narHash": "sha256-LQy14TZp77TwbQf40gg1V3jo8FwJG0jGDkAH+zRHqg8=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "1559d3daa3ecc813a650b79375ea61b6741b8746",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
{
|
||||||
|
description = "Linux build and packaging environment for Nani Translate";
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs }:
|
||||||
|
let
|
||||||
|
supportedSystems = [ "x86_64-linux" ];
|
||||||
|
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||||
|
electronRuntimeLibraries = pkgs: with pkgs; [
|
||||||
|
alsa-lib
|
||||||
|
atk
|
||||||
|
cairo
|
||||||
|
cups
|
||||||
|
dbus
|
||||||
|
expat
|
||||||
|
glib
|
||||||
|
gtk3
|
||||||
|
libdrm
|
||||||
|
libgbm
|
||||||
|
libglvnd
|
||||||
|
libxkbcommon
|
||||||
|
mesa
|
||||||
|
nspr
|
||||||
|
nss
|
||||||
|
pango
|
||||||
|
libx11
|
||||||
|
libxcomposite
|
||||||
|
libxdamage
|
||||||
|
libxext
|
||||||
|
libxfixes
|
||||||
|
libxrandr
|
||||||
|
libxtst
|
||||||
|
];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
electronLibraries = electronRuntimeLibraries pkgs;
|
||||||
|
electronLibraryPath = pkgs.lib.makeLibraryPath electronLibraries;
|
||||||
|
dlopenLibraryPath = pkgs.lib.makeLibraryPath [ pkgs.libglvnd ];
|
||||||
|
naniDmg = pkgs.fetchurl {
|
||||||
|
url = "https://nani-desktop.kiok.jp/artifacts/nani-1.1.0.dmg";
|
||||||
|
hash = "sha256-ARBkZowyJMfdjnZJ7VOQJ9eXRUY2PQOZadkqlGmV5HE=";
|
||||||
|
};
|
||||||
|
electronZip = pkgs.fetchurl {
|
||||||
|
url = "https://github.com/electron/electron/releases/download/v42.5.2/electron-v42.5.2-linux-x64.zip";
|
||||||
|
hash = "sha256-aJ1JKIhIVzKF2P0mUnyGhqYmqIugdO+1xIhknyB1gkY=";
|
||||||
|
};
|
||||||
|
sqliteArchive = pkgs.fetchurl {
|
||||||
|
url = "https://github.com/WiseLibs/better-sqlite3/releases/download/v12.11.1/better-sqlite3-v12.11.1-electron-v146-linux-x64.tar.gz";
|
||||||
|
hash = "sha256-4gIa7tgN5PFSElJeMFZXVqqueQ5o4yKd9tE0X0OAMiE=";
|
||||||
|
};
|
||||||
|
nani = pkgs.buildNpmPackage {
|
||||||
|
pname = "nani-translate-linux";
|
||||||
|
version = "1.1.0";
|
||||||
|
src = self;
|
||||||
|
|
||||||
|
npmDepsHash = "sha256-L5tUThJHvaiS8wa9btUhzJwuJggQ3a/q/aet5drsoW8=";
|
||||||
|
npmInstallFlags = [ "--ignore-scripts" ];
|
||||||
|
dontNpmBuild = true;
|
||||||
|
dontBuild = true;
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
autoPatchelfHook
|
||||||
|
bash
|
||||||
|
binutils
|
||||||
|
coreutils
|
||||||
|
curl
|
||||||
|
findutils
|
||||||
|
gawk
|
||||||
|
gnugrep
|
||||||
|
gnutar
|
||||||
|
makeWrapper
|
||||||
|
p7zip
|
||||||
|
patchelf
|
||||||
|
unzip
|
||||||
|
];
|
||||||
|
|
||||||
|
dontAutoPatchelf = true;
|
||||||
|
doCheck = true;
|
||||||
|
checkPhase = ''
|
||||||
|
runHook preCheck
|
||||||
|
substituteInPlace tests/runtime-packaging.test.mjs \
|
||||||
|
--replace-fail "#!/usr/bin/env bash" "#!${pkgs.bash}/bin/bash"
|
||||||
|
npm test
|
||||||
|
runHook postCheck
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
export HOME="$TMPDIR/home"
|
||||||
|
export NANI_ROOT="$PWD"
|
||||||
|
export NANI_CACHE_DIR="$TMPDIR/cache"
|
||||||
|
export NANI_BUILD_DIR="$TMPDIR/build"
|
||||||
|
export NANI_OUTPUT_DIR="$out/opt/nani"
|
||||||
|
export NANI_REPORT_DIR="$TMPDIR/reports"
|
||||||
|
export NANI_DMG_PATH="${naniDmg}"
|
||||||
|
export NANI_DMG_SHA512="CNfzxftBIR7d3J/iMOPChhixiY7eEMXTkpjsx9yaKuPLGqnr0m90kbU3emg+egl4amOE5s7AoNyT8XQS/v1BvA=="
|
||||||
|
export NANI_VERSION="1.1.0"
|
||||||
|
export NANI_ELECTRON_ZIP_PATH="${electronZip}"
|
||||||
|
export NANI_SQLITE_ARCHIVE_PATH="${sqliteArchive}"
|
||||||
|
export NANI_AUTOPATCHELF=1
|
||||||
|
export NANI_AUTOPATCHELF_LIBRARY_PATH="${electronLibraryPath}"
|
||||||
|
export NANI_AUTOPATCHELF_APPEND_RPATHS="${dlopenLibraryPath}"
|
||||||
|
|
||||||
|
patchShebangs scripts runtime
|
||||||
|
bash scripts/build-app.sh
|
||||||
|
|
||||||
|
mkdir -p "$out/bin" "$out/share/applications" "$out/share/icons/hicolor/512x512/apps"
|
||||||
|
makeWrapper "$out/opt/nani/start.sh" "$out/bin/nani" \
|
||||||
|
--set NANI_APP_DIR "$out/opt/nani"
|
||||||
|
substitute runtime/nani.desktop.template "$out/share/applications/nani.desktop" \
|
||||||
|
--replace-fail "Exec=nani %U" "Exec=$out/bin/nani %U"
|
||||||
|
install -m 0644 "$out/opt/nani/resources/icon.png" \
|
||||||
|
"$out/share/icons/hicolor/512x512/apps/nani.png"
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = nani;
|
||||||
|
nani-translate-linux = nani;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
apps = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
nani = self.packages.${system}.default;
|
||||||
|
electronLibraries = electronRuntimeLibraries pkgs;
|
||||||
|
electronLibraryPath = pkgs.lib.makeLibraryPath electronLibraries;
|
||||||
|
dlopenLibraryPath = pkgs.lib.makeLibraryPath [ pkgs.libglvnd ];
|
||||||
|
naniDmg = pkgs.fetchurl {
|
||||||
|
url = "https://nani-desktop.kiok.jp/artifacts/nani-1.1.0.dmg";
|
||||||
|
hash = "sha256-ARBkZowyJMfdjnZJ7VOQJ9eXRUY2PQOZadkqlGmV5HE=";
|
||||||
|
};
|
||||||
|
localBuild = pkgs.writeShellApplication {
|
||||||
|
name = "nani-local-build";
|
||||||
|
runtimeInputs = electronLibraries ++ (with pkgs; [
|
||||||
|
auto-patchelf
|
||||||
|
binutils
|
||||||
|
coreutils
|
||||||
|
curl
|
||||||
|
findutils
|
||||||
|
gawk
|
||||||
|
gnugrep
|
||||||
|
gnutar
|
||||||
|
nodejs_22
|
||||||
|
p7zip
|
||||||
|
patchelf
|
||||||
|
unzip
|
||||||
|
]);
|
||||||
|
text = ''
|
||||||
|
export NANI_AUTOPATCHELF=1
|
||||||
|
export NANI_AUTOPATCHELF_LIBRARY_PATH="${electronLibraryPath}"
|
||||||
|
export NANI_AUTOPATCHELF_APPEND_RPATHS="${dlopenLibraryPath}"
|
||||||
|
export NANI_DMG_PATH="${naniDmg}"
|
||||||
|
export NANI_DMG_SHA512="CNfzxftBIR7d3J/iMOPChhixiY7eEMXTkpjsx9yaKuPLGqnr0m90kbU3emg+egl4amOE5s7AoNyT8XQS/v1BvA=="
|
||||||
|
export NANI_VERSION=1.1.0
|
||||||
|
exec bash "$PWD/scripts/build-app.sh" "$@"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = {
|
||||||
|
type = "app";
|
||||||
|
program = "${nani}/bin/nani";
|
||||||
|
};
|
||||||
|
build = {
|
||||||
|
type = "app";
|
||||||
|
program = "${localBuild}/bin/nani-local-build";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
devShells = forAllSystems (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
electronLibraries = electronRuntimeLibraries pkgs;
|
||||||
|
electronLibraryPath = pkgs.lib.makeLibraryPath electronLibraries;
|
||||||
|
dlopenLibraryPath = pkgs.lib.makeLibraryPath [ pkgs.libglvnd ];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = electronLibraries ++ (with pkgs; [
|
||||||
|
auto-patchelf
|
||||||
|
binutils
|
||||||
|
coreutils
|
||||||
|
curl
|
||||||
|
desktop-file-utils
|
||||||
|
findutils
|
||||||
|
gawk
|
||||||
|
gnugrep
|
||||||
|
gnutar
|
||||||
|
nodejs_22
|
||||||
|
p7zip
|
||||||
|
patchelf
|
||||||
|
shellcheck
|
||||||
|
unzip
|
||||||
|
]);
|
||||||
|
shellHook = ''
|
||||||
|
export NANI_AUTOPATCHELF=1
|
||||||
|
export NANI_AUTOPATCHELF_LIBRARY_PATH="${electronLibraryPath}"
|
||||||
|
export NANI_AUTOPATCHELF_APPEND_RPATHS="${dlopenLibraryPath}"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
homeManagerModules = rec {
|
||||||
|
default = import ./nix/home-manager-module.nix { inherit self; };
|
||||||
|
nani-translate-linux = default;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
command -v npm >/dev/null 2>&1 || {
|
||||||
|
printf '[nani] error: required command not found: npm\n' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
exec make bootstrap
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{ self }:
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.programs.naniTranslateLinux;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.programs.naniTranslateLinux = {
|
||||||
|
enable = lib.mkEnableOption "Nani Translate for Linux";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
defaultText = lib.literalExpression ''
|
||||||
|
inputs.nani-translate-linux.packages.''${pkgs.stdenv.hostPlatform.system}.default
|
||||||
|
'';
|
||||||
|
description = "Nani Translate Linux package to install.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
home.packages = [ cfg.package ];
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+137
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"name": "nani-translate-linux",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "nani-translate-linux",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@electron/asar": "4.2.1",
|
||||||
|
"yaml": "2.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@electron/asar": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-rGyEe7iy52zxiWuihV/h/AqQrFkx7YnUUJKW1bdufVDHCzIMP/XDrDI/NOzv/LLtzt3NduAzNa475WRmRnmswQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "^13.0.2",
|
||||||
|
"minimatch": "^10.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"asar": "bin/asar.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/brace-expansion": {
|
||||||
|
"version": "5.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "13.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||||
|
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch": "^10.2.2",
|
||||||
|
"minipass": "^7.1.3",
|
||||||
|
"path-scurry": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "11.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||||
|
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "10.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
|
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^5.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^11.0.0",
|
||||||
|
"minipass": "^7.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "nani-translate-linux",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Unofficial local Linux build wrapper for Nani Translate",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"check": "node --check scripts/analyze-upstream.mjs && node --check scripts/patch-asar.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@electron/asar": "4.2.1",
|
||||||
|
"yaml": "2.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Nani Translate
|
||||||
|
Comment=Translate text with Nani
|
||||||
|
Exec=nani %U
|
||||||
|
Icon=nani
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;
|
||||||
|
MimeType=x-scheme-handler/naniapp;
|
||||||
|
StartupNotify=true
|
||||||
|
StartupWMClass=Nani
|
||||||
Executable
+73
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
launcher_path="$(readlink -f -- "$0")"
|
||||||
|
launcher_dir="$(cd "$(dirname "$launcher_path")" && pwd)"
|
||||||
|
app_dir="${NANI_APP_DIR:-$launcher_dir}"
|
||||||
|
electron="$app_dir/electron"
|
||||||
|
|
||||||
|
if [[ "${APPIMAGE:-}" = /* ]]; then
|
||||||
|
NANI_LAUNCHER_PATH="$(readlink -f -- "$APPIMAGE")"
|
||||||
|
else
|
||||||
|
NANI_LAUNCHER_PATH="$launcher_path"
|
||||||
|
fi
|
||||||
|
export NANI_LAUNCHER_PATH
|
||||||
|
|
||||||
|
if [ ! -x "$electron" ]; then
|
||||||
|
printf 'nani: Electron executable not found: %s\n' "$electron" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ozone_platform="${NANI_OZONE_PLATFORM:-auto}"
|
||||||
|
case "$ozone_platform" in
|
||||||
|
x11|wayland|auto) ;;
|
||||||
|
*)
|
||||||
|
printf 'nani: NANI_OZONE_PLATFORM must be x11, wayland, or auto (got %s)\n' \
|
||||||
|
"$ozone_platform" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
has_ozone_argument=false
|
||||||
|
for argument in "$@"; do
|
||||||
|
case "$argument" in
|
||||||
|
--ozone-platform|--ozone-platform=*|--ozone-platform-hint|--ozone-platform-hint=*)
|
||||||
|
has_ozone_argument=true
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
electron_arguments=()
|
||||||
|
if [ "$has_ozone_argument" = false ]; then
|
||||||
|
if [ "$ozone_platform" = auto ]; then
|
||||||
|
if [ -n "${DISPLAY:-}" ]; then
|
||||||
|
ozone_platform=x11
|
||||||
|
elif [ -n "${WAYLAND_DISPLAY:-}" ]; then
|
||||||
|
ozone_platform=wayland
|
||||||
|
else
|
||||||
|
ozone_platform=x11
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
electron_arguments+=("--ozone-platform=$ozone_platform")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${NANI_CDP_PORT:-}" ]; then
|
||||||
|
if [[ ! "$NANI_CDP_PORT" =~ ^[0-9]+$ ]] ||
|
||||||
|
(( 10#$NANI_CDP_PORT < 1 || 10#$NANI_CDP_PORT > 65535 )); then
|
||||||
|
printf 'nani: NANI_CDP_PORT must be an integer from 1 to 65535\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
electron_arguments+=(
|
||||||
|
"--remote-debugging-address=127.0.0.1"
|
||||||
|
"--remote-debugging-port=$NANI_CDP_PORT"
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/nani-translate-linux"
|
||||||
|
mkdir -p "$cache_dir"
|
||||||
|
log_file="$cache_dir/nani.log"
|
||||||
|
printf '\n[%s] starting Nani\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >>"$log_file"
|
||||||
|
|
||||||
|
exec "$electron" "${electron_arguments[@]}" "$@" >>"$log_file" 2>&1
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
Executable
+160
@@ -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"
|
||||||
Executable
+57
@@ -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"
|
||||||
Executable
+24
@@ -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"
|
||||||
Executable
+59
@@ -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"
|
||||||
Executable
+49
@@ -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"
|
||||||
Executable
+131
@@ -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 ?? ""}`);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Executable
+56
@@ -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",
|
||||||
|
);
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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" };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const OcrAccuracy = Object.freeze({ Fast: 0, Accurate: 1 });
|
||||||
|
|
||||||
|
async function recognize() {
|
||||||
|
throw new Error(
|
||||||
|
"Nani screenshot OCR is not supported by the unofficial Linux client. " +
|
||||||
|
"Text translation remains available.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { OcrAccuracy, recognize };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "@napi-rs/system-ocr-linux-x64-gnu",
|
||||||
|
"version": "1.0.2-nani-linux-stub",
|
||||||
|
"private": true,
|
||||||
|
"main": "index.js"
|
||||||
|
}
|
||||||
@@ -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