From 845dde50ad08b0ec6e2e6b7dbf942b3ef9f9142d Mon Sep 17 00:00:00 2001 From: yuta Date: Sat, 1 Aug 2026 20:21:37 +0900 Subject: [PATCH] Initial commit --- .github/workflows/ci.yml | 50 +++ .github/workflows/upstream-watchdog.yml | 170 +++++++++ .gitignore | 9 + Makefile | 60 +++ THIRD_PARTY_NOTICES.md | 31 ++ flake.lock | 27 ++ flake.nix | 223 +++++++++++ install.sh | 13 + nix/home-manager-module.nix | 28 ++ package-lock.json | 137 +++++++ package.json | 18 + runtime/nani.desktop.template | 11 + runtime/start.sh.template | 73 ++++ scripts/analyze-upstream.mjs | 162 ++++++++ scripts/build-app.sh | 160 ++++++++ scripts/build-appimage.sh | 57 +++ scripts/extract-dmg.sh | 24 ++ scripts/fetch-upstream.sh | 59 +++ scripts/install-desktop.sh | 49 +++ scripts/lib/build-metadata.mjs | 131 +++++++ scripts/lib/common.sh | 102 ++++++ scripts/lib/resolve-upstream.mjs | 56 +++ scripts/patch-asar.mjs | 346 ++++++++++++++++++ scripts/patches/descriptor.mjs | 45 +++ scripts/patches/disable-updater.mjs | 30 ++ scripts/patches/engine.mjs | 100 +++++ scripts/patches/index.mjs | 15 + scripts/patches/lib.mjs | 66 ++++ scripts/patches/linux-onboarding.mjs | 88 +++++ scripts/patches/linux-tray.mjs | 21 ++ scripts/patches/ocr-stub.mjs | 44 +++ scripts/patches/prune-non-linux.mjs | 54 +++ scripts/patches/xdg-autostart.mjs | 34 ++ .../system-ocr-linux-x64-gnu/index.js | 12 + .../system-ocr-linux-x64-gnu/package.json | 6 + tests/asar-patches.test.mjs | 237 ++++++++++++ tests/runtime-packaging.test.mjs | 103 ++++++ tests/upstream-resolver.test.mjs | 71 ++++ 38 files changed, 2922 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/upstream-watchdog.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100755 install.sh create mode 100644 nix/home-manager-module.nix create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 runtime/nani.desktop.template create mode 100755 runtime/start.sh.template create mode 100644 scripts/analyze-upstream.mjs create mode 100755 scripts/build-app.sh create mode 100755 scripts/build-appimage.sh create mode 100755 scripts/extract-dmg.sh create mode 100755 scripts/fetch-upstream.sh create mode 100755 scripts/install-desktop.sh create mode 100755 scripts/lib/build-metadata.mjs create mode 100644 scripts/lib/common.sh create mode 100755 scripts/lib/resolve-upstream.mjs create mode 100644 scripts/patch-asar.mjs create mode 100644 scripts/patches/descriptor.mjs create mode 100644 scripts/patches/disable-updater.mjs create mode 100644 scripts/patches/engine.mjs create mode 100644 scripts/patches/index.mjs create mode 100644 scripts/patches/lib.mjs create mode 100644 scripts/patches/linux-onboarding.mjs create mode 100644 scripts/patches/linux-tray.mjs create mode 100644 scripts/patches/ocr-stub.mjs create mode 100644 scripts/patches/prune-non-linux.mjs create mode 100644 scripts/patches/xdg-autostart.mjs create mode 100644 stubs/@napi-rs/system-ocr-linux-x64-gnu/index.js create mode 100644 stubs/@napi-rs/system-ocr-linux-x64-gnu/package.json create mode 100644 tests/asar-patches.test.mjs create mode 100644 tests/runtime-packaging.test.mjs create mode 100644 tests/upstream-resolver.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ef283b5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/upstream-watchdog.yml b/.github/workflows/upstream-watchdog.yml new file mode 100644 index 0000000..979f38e --- /dev/null +++ b/.github/workflows/upstream-watchdog.yml @@ -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, + }); + } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b89d66d --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +nani-app/ +build/ +dist/ +.cache/ +*.dmg +*.part +patch-report.json +upstream-analysis.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..49b589e --- /dev/null +++ b/Makefile @@ -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 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..4fe34bb --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -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. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..e6d08bb --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..9ce924e --- /dev/null +++ b/flake.nix @@ -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; + }; + }; +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ed6372c --- /dev/null +++ b/install.sh @@ -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 diff --git a/nix/home-manager-module.nix b/nix/home-manager-module.nix new file mode 100644 index 0000000..61e5956 --- /dev/null +++ b/nix/home-manager-module.nix @@ -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 ]; + }; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..708319b --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..82ce57e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/runtime/nani.desktop.template b/runtime/nani.desktop.template new file mode 100644 index 0000000..16371eb --- /dev/null +++ b/runtime/nani.desktop.template @@ -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 diff --git a/runtime/start.sh.template b/runtime/start.sh.template new file mode 100755 index 0000000..5614dbe --- /dev/null +++ b/runtime/start.sh.template @@ -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 diff --git a/scripts/analyze-upstream.mjs b/scripts/analyze-upstream.mjs new file mode 100644 index 0000000..e980613 --- /dev/null +++ b/scripts/analyze-upstream.mjs @@ -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 [--dmg ] [--output ]"); + 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(`${escaped}\\s*([^<]+)`))?.[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 }); +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh new file mode 100755 index 0000000..cf251f6 --- /dev/null +++ b/scripts/build-app.sh @@ -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" diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh new file mode 100755 index 0000000..94916bd --- /dev/null +++ b/scripts/build-appimage.sh @@ -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" diff --git a/scripts/extract-dmg.sh b/scripts/extract-dmg.sh new file mode 100755 index 0000000..9f772a6 --- /dev/null +++ b/scripts/extract-dmg.sh @@ -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" diff --git a/scripts/fetch-upstream.sh b/scripts/fetch-upstream.sh new file mode 100755 index 0000000..5207521 --- /dev/null +++ b/scripts/fetch-upstream.sh @@ -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" diff --git a/scripts/install-desktop.sh b/scripts/install-desktop.sh new file mode 100755 index 0000000..26ec154 --- /dev/null +++ b/scripts/install-desktop.sh @@ -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" diff --git a/scripts/lib/build-metadata.mjs b/scripts/lib/build-metadata.mjs new file mode 100755 index 0000000..a2cee4b --- /dev/null +++ b/scripts/lib/build-metadata.mjs @@ -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( + /CFBundle(?:ShortVersionString|Version)<\/key>\s*([^<]+)<\/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 ?? ""}`); +} diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..f58e699 --- /dev/null +++ b/scripts/lib/common.sh @@ -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" +} diff --git a/scripts/lib/resolve-upstream.mjs b/scripts/lib/resolve-upstream.mjs new file mode 100755 index 0000000..1c81c47 --- /dev/null +++ b/scripts/lib/resolve-upstream.mjs @@ -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", +); diff --git a/scripts/patch-asar.mjs b/scripts/patch-asar.mjs new file mode 100644 index 0000000..353262f --- /dev/null +++ b/scripts/patch-asar.mjs @@ -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(); diff --git a/scripts/patches/descriptor.mjs b/scripts/patches/descriptor.mjs new file mode 100644 index 0000000..57a8938 --- /dev/null +++ b/scripts/patches/descriptor.mjs @@ -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); + }); +} diff --git a/scripts/patches/disable-updater.mjs b/scripts/patches/disable-updater.mjs new file mode 100644 index 0000000..2a305fe --- /dev/null +++ b/scripts/patches/disable-updater.mjs @@ -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" }; + }, +}); diff --git a/scripts/patches/engine.mjs b/scripts/patches/engine.mjs new file mode 100644 index 0000000..38e6276 --- /dev/null +++ b/scripts/patches/engine.mjs @@ -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, + }); +} diff --git a/scripts/patches/index.mjs b/scripts/patches/index.mjs new file mode 100644 index 0000000..c413e13 --- /dev/null +++ b/scripts/patches/index.mjs @@ -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, +]; diff --git a/scripts/patches/lib.mjs b/scripts/patches/lib.mjs new file mode 100644 index 0000000..715432b --- /dev/null +++ b/scripts/patches/lib.mjs @@ -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)]), + ); +} diff --git a/scripts/patches/linux-onboarding.mjs b/scripts/patches/linux-onboarding.mjs new file mode 100644 index 0000000..98f0120 --- /dev/null +++ b/scripts/patches/linux-onboarding.mjs @@ -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" }; + }, +}); diff --git a/scripts/patches/linux-tray.mjs b/scripts/patches/linux-tray.mjs new file mode 100644 index 0000000..83311f4 --- /dev/null +++ b/scripts/patches/linux-tray.mjs @@ -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", + }; + }, +}); diff --git a/scripts/patches/ocr-stub.mjs b/scripts/patches/ocr-stub.mjs new file mode 100644 index 0000000..83c347d --- /dev/null +++ b/scripts/patches/ocr-stub.mjs @@ -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 }; + }, +}); diff --git a/scripts/patches/prune-non-linux.mjs b/scripts/patches/prune-non-linux.mjs new file mode 100644 index 0000000..8327c96 --- /dev/null +++ b/scripts/patches/prune-non-linux.mjs @@ -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, + }; + }, +}); diff --git a/scripts/patches/xdg-autostart.mjs b/scripts/patches/xdg-autostart.mjs new file mode 100644 index 0000000..d32ae00 --- /dev/null +++ b/scripts/patches/xdg-autostart.mjs @@ -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" }; + }, +}); diff --git a/stubs/@napi-rs/system-ocr-linux-x64-gnu/index.js b/stubs/@napi-rs/system-ocr-linux-x64-gnu/index.js new file mode 100644 index 0000000..e3f4580 --- /dev/null +++ b/stubs/@napi-rs/system-ocr-linux-x64-gnu/index.js @@ -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 }; diff --git a/stubs/@napi-rs/system-ocr-linux-x64-gnu/package.json b/stubs/@napi-rs/system-ocr-linux-x64-gnu/package.json new file mode 100644 index 0000000..9470aba --- /dev/null +++ b/stubs/@napi-rs/system-ocr-linux-x64-gnu/package.json @@ -0,0 +1,6 @@ +{ + "name": "@napi-rs/system-ocr-linux-x64-gnu", + "version": "1.0.2-nani-linux-stub", + "private": true, + "main": "index.js" +} diff --git a/tests/asar-patches.test.mjs b/tests/asar-patches.test.mjs new file mode 100644 index 0000000..b21bcce --- /dev/null +++ b/tests/asar-patches.test.mjs @@ -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/, + ); +}); diff --git a/tests/runtime-packaging.test.mjs b/tests/runtime-packaging.test.mjs new file mode 100644 index 0000000..88a439c --- /dev/null +++ b/tests/runtime-packaging.test.mjs @@ -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")); +}); diff --git a/tests/upstream-resolver.test.mjs b/tests/upstream-resolver.test.mjs new file mode 100644 index 0000000..323c3c5 --- /dev/null +++ b/tests/upstream-resolver.test.mjs @@ -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/); + }, + ); +});