From 1213203228ff988cca973d1eac63f8be740c421a Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 10 Sep 2026 13:14:16 -0700 Subject: [PATCH] feat(launcher): use private Bun for all release launchers --- .github/workflows/prerelease.yml | 11 +- .github/workflows/quality-gate.yml | 25 +++- .github/workflows/release.yml | 11 +- Makefile | 10 +- README.md | 8 +- changes/bundled-bun-runtime-docs.md | 4 + changes/bundled-bun-runtime.md | 4 +- docs-site/installation.md | 31 ++--- docs-site/launcher-script.md | 8 +- docs-site/usage.md | 2 +- docs/RELEASING.md | 6 + docs/architecture/README.md | 2 +- docs/architecture/layering.md | 2 +- launcher/commands/update-command.test.ts | 81 +++++++++++ launcher/commands/update-command.ts | 66 ++++++--- package.json | 13 +- packaging/aur/subminer-bin/.SRCINFO | 1 - packaging/aur/subminer-bin/PKGBUILD | 1 - scripts/build-changelog.test.ts | 2 + scripts/build-changelog.ts | 4 +- scripts/build-launcher.ts | 54 ++++++++ scripts/update-aur-package.test.ts | 2 + scripts/verify-generated-launcher.sh | 37 +++-- src/main/runtime/command-line-launcher.ts | 41 +++--- src/main/runtime/managed-launcher.test.ts | 69 +++++++--- src/main/runtime/managed-launcher.ts | 59 ++++---- .../runtime/posix-launcher-bootstrap.test.ts | 66 +++++++++ src/main/runtime/posix-launcher-bootstrap.ts | 56 ++++++++ src/main/runtime/prepare-launcher-runtime.ts | 24 ++++ .../runtime/update/appimage-updater.test.ts | 48 ++++++- src/main/runtime/update/appimage-updater.ts | 15 +++ .../runtime/update/launcher-updater.test.ts | 72 +++++++++- src/main/runtime/update/launcher-updater.ts | 12 ++ .../runtime/update/update-service-runtime.ts | 1 + .../windows-launcher-bootstrap.test.ts | 127 ++++++++++++++++++ .../runtime/windows-launcher-bootstrap.ts | 75 +++++++++++ src/prerelease-workflow.test.ts | 6 +- src/release-workflow.test.ts | 30 +++-- 38 files changed, 905 insertions(+), 181 deletions(-) create mode 100644 changes/bundled-bun-runtime-docs.md create mode 100644 scripts/build-launcher.ts create mode 100644 src/main/runtime/posix-launcher-bootstrap.test.ts create mode 100644 src/main/runtime/posix-launcher-bootstrap.ts create mode 100644 src/main/runtime/prepare-launcher-runtime.ts create mode 100644 src/main/runtime/windows-launcher-bootstrap.test.ts create mode 100644 src/main/runtime/windows-launcher-bootstrap.ts diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 7c4dc29c..16a2927a 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -196,7 +196,7 @@ jobs: bun run build - name: Verify managed Windows launcher - run: bun test src/main/runtime/managed-launcher.test.ts + run: bun test src/main/runtime/managed-launcher.test.ts src/main/runtime/windows-launcher-bootstrap.test.ts - name: Build unsigned Windows artifacts run: bun run build:win:unsigned @@ -259,11 +259,11 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Build Bun subminer wrapper + - name: Build launcher runtime artifacts run: make build-launcher - - name: Verify Bun subminer wrapper - run: dist/launcher/subminer --help >/dev/null + - name: Smoke launcher bundle + run: bun dist/launcher/subminer.js --help >/dev/null - name: Enforce generated launcher workflow run: bash scripts/verify-generated-launcher.sh @@ -286,7 +286,7 @@ jobs: - name: Generate checksums run: | shopt -s nullglob - files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer) + files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd) if [ "${#files[@]}" -eq 0 ]; then echo "No release artifacts found for checksum generation." exit 1 @@ -334,6 +334,7 @@ jobs: release/*.blockmap release/SHA256SUMS.txt dist/launcher/subminer + dist/launcher/subminer.cmd ) if [ "${#artifacts[@]}" -eq 0 ]; then diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index ecf50aa8..042ac4d5 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -7,6 +7,25 @@ permissions: contents: read jobs: + launcher-runtime: + strategy: + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Verify native launcher bootstrap and runtime staging + run: bun test src/main/runtime/managed-launcher.test.ts src/main/runtime/windows-launcher-bootstrap.test.ts src/main/runtime/posix-launcher-bootstrap.test.ts + quality-gate: runs-on: ubuntu-latest steps: @@ -110,11 +129,11 @@ jobs: - name: Security audit run: bun audit --audit-level high - - name: Build Bun subminer wrapper + - name: Build launcher runtime artifacts run: make build-launcher - - name: Verify Bun subminer wrapper - run: dist/launcher/subminer --help >/dev/null + - name: Smoke launcher bundle + run: bun dist/launcher/subminer.js --help >/dev/null - name: Enforce generated launcher workflow run: bash scripts/verify-generated-launcher.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc805e66..d18c8197 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -195,7 +195,7 @@ jobs: bun run build - name: Verify managed Windows launcher - run: bun test src/main/runtime/managed-launcher.test.ts + run: bun test src/main/runtime/managed-launcher.test.ts src/main/runtime/windows-launcher-bootstrap.test.ts - name: Build unsigned Windows artifacts run: bun run build:win:unsigned @@ -258,11 +258,11 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Build Bun subminer wrapper + - name: Build launcher runtime artifacts run: make build-launcher - - name: Verify Bun subminer wrapper - run: dist/launcher/subminer --help >/dev/null + - name: Smoke launcher bundle + run: bun dist/launcher/subminer.js --help >/dev/null - name: Enforce generated launcher workflow run: bash scripts/verify-generated-launcher.sh @@ -285,7 +285,7 @@ jobs: - name: Generate checksums run: | shopt -s nullglob - files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer) + files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd) if [ "${#files[@]}" -eq 0 ]; then echo "No release artifacts found for checksum generation." exit 1 @@ -351,6 +351,7 @@ jobs: release/*.blockmap release/SHA256SUMS.txt dist/launcher/subminer + dist/launcher/subminer.cmd ) if [ "${#artifacts[@]}" -eq 0 ]; then diff --git a/Makefile b/Makefile index 09533805..59adc5e3 100644 --- a/Makefile +++ b/Makefile @@ -160,14 +160,8 @@ build-macos-unsigned: deps @bun run build:mac:unsigned build-launcher: - @printf '%s\n' "[INFO] Bundling launcher script" - @install -d "$(dir $(LAUNCHER_OUT))" - @bun build ./launcher/main.ts --target=bun --packages=bundle --outfile="$(LAUNCHER_OUT)" - @if ! head -1 "$(LAUNCHER_OUT)" | grep -q '^#!/usr/bin/env bun'; then \ - { printf '#!/usr/bin/env bun\n'; cat "$(LAUNCHER_OUT)"; } > "$(LAUNCHER_OUT).tmp" && mv "$(LAUNCHER_OUT).tmp" "$(LAUNCHER_OUT)"; \ - fi - @chmod +x "$(LAUNCHER_OUT)" - @printf '%s\n' "[INFO] Launcher artifact: $(LAUNCHER_OUT)" + @printf '%s\n' "[INFO] Building launcher runtime artifacts" + @bun run build:launcher clean: @printf '%s\n' "[INFO] Removing build artifacts" diff --git a/README.md b/README.md index afc0d8aa..3790bf12 100644 --- a/README.md +++ b/README.md @@ -191,9 +191,9 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppI && chmod +x ~/.local/bin/SubMiner.AppImage ``` -The AppImage is all you need. First-run setup can install the optional `subminer` command-line launcher. That launcher uses Bun bundled with the AppImage, so you do not need Bun installed or on `PATH`. +The AppImage is all you need. First-run setup can install the optional `subminer` command-line launcher. Every current launcher uses Bun included with the app, so you do not need Bun installed or on `PATH`. -The separately downloaded `subminer` release asset is for standalone installs and still requires [Bun](https://bun.sh): +You can also download the launcher wrapper directly: ```bash wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \ @@ -214,6 +214,8 @@ Download the latest DMG from [GitHub Releases](https://github.com/ksyasuda/SubMi Download and run the latest installer (`.exe`) from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). +For terminal use, download `subminer.cmd`. It locates the installed app and uses its private Bun runtime. +
@@ -225,7 +227,7 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so ### 2. Launch & Set Up -Run the installed app and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher. It installs a wrapper that runs with the app's private Bun runtime. +Run the installed app and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher. Setup records a custom app location when needed, and the wrapper runs with the app's private Bun runtime. ```bash # Linux diff --git a/changes/bundled-bun-runtime-docs.md b/changes/bundled-bun-runtime-docs.md new file mode 100644 index 00000000..71948ba6 --- /dev/null +++ b/changes/bundled-bun-runtime-docs.md @@ -0,0 +1,4 @@ +type: docs +area: launcher + +- Documented private-runtime launcher installation, custom app locations, legacy launcher migration, and package-managed updates. diff --git a/changes/bundled-bun-runtime.md b/changes/bundled-bun-runtime.md index 149c86df..c1661b8f 100644 --- a/changes/bundled-bun-runtime.md +++ b/changes/bundled-bun-runtime.md @@ -1,4 +1,4 @@ -type: added +type: changed area: launcher -- Packaged apps now include Bun and can install a managed `subminer` launcher without requiring a separate Bun installation. +- Made every installed and downloadable launcher use the Bun runtime included with SubMiner. Added a Windows `subminer.cmd` download, persistent private runtime caches for Linux and Windows, and migration for recognized legacy launchers. diff --git a/docs-site/installation.md b/docs-site/installation.md index 7fb3063f..342a6f43 100644 --- a/docs-site/installation.md +++ b/docs-site/installation.md @@ -211,7 +211,7 @@ The launcher's picker tools (`fzf`, `rofi`, `chafa`, `ffmpegthumbnailer`) are fo ### Arch Linux (AUR) {#arch-aur} -Install [`subminer-bin`](https://aur.archlinux.org/packages/subminer-bin) from the AUR. The package includes the SubMiner AppImage and the standalone `subminer` launcher, which requires system Bun. +Install [`subminer-bin`](https://aur.archlinux.org/packages/subminer-bin) from the AUR. The package includes the SubMiner AppImage and its launcher wrapper. Bun is included with the app, so the package has no Bun dependency. Install updates through your AUR helper or package manager. ```bash paru -S subminer-bin @@ -236,9 +236,7 @@ chmod +x ~/.local/bin/SubMiner.AppImage ``` ::: tip Launcher install is optional -First-run setup can install the `subminer` command-line launcher for you. It uses Bun bundled with the AppImage, so it does not need a separate Bun installation or a Bun entry on `PATH`. - -The separately downloaded launcher is a standalone Bun script. See [manual launcher install](#manual-launcher-install-linux) if that is what you want. +First-run setup can install the `subminer` command-line launcher for you. It uses Bun bundled with the AppImage, so it does not need a separate Bun installation or a Bun entry on `PATH`. The downloaded wrapper works the same way. See [manual launcher install](#manual-launcher-install-linux). ::: ### macOS (DMG) {#macos-dmg} @@ -257,9 +255,7 @@ xattr -d com.apple.quarantine /Applications/SubMiner.app 2. Enable SubMiner in the list (add it if it does not appear) ::: tip Launcher install is optional -First-run setup can install the `subminer` command-line launcher for you. It uses Bun bundled inside `SubMiner.app`, so it does not need a separate Bun installation or a Bun entry on `PATH`. - -The separately downloaded launcher is a standalone Bun script. See [manual launcher install](#manual-launcher-install-macos) if that is what you want. +First-run setup can install the `subminer` command-line launcher for you. It uses Bun bundled inside `SubMiner.app`, so it does not need a separate Bun installation or a Bun entry on `PATH`. The downloaded wrapper works the same way. See [manual launcher install](#manual-launcher-install-macos). ::: ### Windows (installer) {#windows-installer} @@ -268,6 +264,7 @@ Download the latest installer from [GitHub Releases](https://github.com/ksyasuda - `SubMiner-.exe` - installer (recommended) - `SubMiner--win.zip` - portable fallback +- `subminer.cmd` - optional terminal launcher wrapper Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup. @@ -351,7 +348,7 @@ The setup wizard walks you through: The `Finish setup` button requires a config file and at least one Yomitan dictionary. The launcher is optional and never blocks setup completion. -On Linux and macOS, setup selects a writable directory already on your terminal `PATH`. If it cannot find one, it creates `~/.local/bin` and shows the `export PATH=...` command to run. Add that command to your shell configuration yourself if you want it in future terminals. Setup never edits shell configuration files. On Windows, setup adds only the wrapper directory to the user `PATH`. +On Linux and macOS, setup selects a writable directory already on your terminal `PATH`. If it cannot find one, it creates `~/.local/bin` and shows the `export PATH=...` command to run. Add that command to your shell configuration yourself if you want it in future terminals. Setup never edits shell configuration files. On Windows, setup adds only the wrapper directory to the user `PATH`. Setup stores a custom app location so the wrapper can find an AppImage or app bundle outside the usual install directories. > [!TIP] > You can re-open the setup wizard at any time with `subminer app --setup` or `SubMiner.AppImage --setup`. @@ -400,9 +397,9 @@ subminer --update SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer`, the rofi theme at `SubMiner/themes/subminer.rasi`, and the scoped Matroska thumbnailer registration under `SubMiner/thumbnailers`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself. -The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. Managed `subminer` wrappers update with the app and never replace themselves with the standalone release script. On Linux it replaces the running `.AppImage` in place via `electron-updater` and refreshes managed support assets from `subminer-assets.tar.gz`. The managed launcher payload refreshes when the updated app starts; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge. +The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. Current `subminer` wrappers remain small bootstraps that locate the installed app and its private runtime. On Linux the updater replaces the running `.AppImage` in place via `electron-updater` and refreshes managed support assets from `subminer-assets.tar.gz`. The next launcher invocation detects the changed AppImage fingerprint and prepares the matching Bun and CLI cache before running the command. App startup also refreshes this payload and migrates recognized writable legacy launchers. AppImages managed by a system package, for example the AUR `/opt/SubMiner/SubMiner.AppImage`, are skipped so the package manager stays in charge. -On Linux, `subminer -u` updates the AppImage and managed support assets directly, even when the app is not running. Standalone launchers also update their own script. A launcher installed by the desktop app refreshes its runtime and script when the updated app starts. +On Linux, `subminer -u` updates the AppImage and managed support assets directly, even when the app is not running. The launcher cache refreshes when the app fingerprint changes. AUR installs remain under package-manager control and should be updated through the package manager. ## How it all fits together @@ -434,14 +431,11 @@ SubMiner injects the bundled mpv plugin at runtime, so there is nothing to insta ## Manual launcher install -This section installs the standalone `subminer` release asset. It uses a [Bun](https://bun.sh) shebang, so Bun must be installed. A launcher installed from the desktop app does not need this setup. +Current launcher downloads use Bun included in the SubMiner app. The wrapper searches normal install locations and honors `SUBMINER_BINARY_PATH`; Linux also honors `SUBMINER_APPIMAGE_PATH`. ### Linux {#manual-launcher-install-linux} ```bash -# Install Bun -curl -fsSL https://bun.sh/install | bash - # Download the launcher wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer chmod +x ~/.local/bin/subminer @@ -450,15 +444,16 @@ chmod +x ~/.local/bin/subminer ### macOS {#manual-launcher-install-macos} ```bash -# Install Bun -curl -fsSL https://bun.sh/install | bash - # Download the launcher sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer sudo chmod +x /usr/local/bin/subminer ``` -`make install` and the AUR package also install the standalone launcher, so they continue to require system Bun. +### Windows {#manual-launcher-install-windows} + +Download `subminer.cmd` from GitHub Releases and place it in a directory on your user `PATH`. It finds the installed app in the normal per-user or Program Files location. Set `SUBMINER_BINARY_PATH` if you use a portable or custom install. + +Launchers installed before the private-runtime change may still be bundled JavaScript with a Bun shebang. Those old files need system Bun until a current app startup migrates a recognized writable launcher, or until you replace one with the current release wrapper. ## Optional extras diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index 29adf48b..6df181c0 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -2,11 +2,13 @@ The `subminer` launcher handles video selection, mpv startup, and overlay management in one script. It guarantees mpv starts with the right IPC socket and SubMiner defaults. On Windows, the **SubMiner mpv** shortcut remains the recommended playback entry point. -When first-run setup installs the launcher from a packaged desktop app, it installs a small managed wrapper. The wrapper invokes that app's private Bun runtime by absolute path, so you do not need Bun installed or on `PATH`. Only the directory containing `subminer` needs to be on `PATH`. +The launcher is a small wrapper around the CLI bundled in the desktop app. It locates a normal SubMiner installation, or uses `SUBMINER_BINARY_PATH` when you set a custom executable. Linux also accepts `SUBMINER_APPIMAGE_PATH`. First-run setup records the selected app location for the wrapper. You do not need Bun installed or on `PATH`; only the directory containing `subminer` needs to be on `PATH`. -On macOS, the wrapper points at the installed app resources. Reinstall the launcher from setup after moving the app. On Windows, the wrapper runs a versioned private Bun copy under `%LOCALAPPDATA%\SubMiner\launcher-runtime` while using the launcher script and app path from the installed app. Old runtime versions are removed when no running launcher is using them. On Linux, setup copies the launcher runtime and script to `${XDG_DATA_HOME:-~/.local/share}/SubMiner/launcher`; the wrapper keeps working after the AppImage closes. SubMiner refreshes managed payloads on the next app start after an app version change, and reinstalling the launcher refreshes them too. +On macOS, the wrapper runs Bun and the CLI directly from `SubMiner.app/Contents/Resources`. On Windows, `subminer.cmd` stages a versioned private Bun copy under `%LOCALAPPDATA%\SubMiner\launcher-runtime/` and runs the CLI from the current app. Keeping the executable outside the app avoids locking an updater-owned file while a launcher is running. Old runtime versions are removed when no running launcher is using them. -The separately downloaded `subminer` release asset is a standalone Bun script. It, `make install`, and the AUR package require system Bun. +On Linux, the first launch caches Bun and its matching CLI and license files under `${XDG_DATA_HOME:-~/.local/share}/SubMiner/launcher`. Later launches make one `stat` call against the AppImage and run the cache without starting Electron. A missing cache or changed app fingerprint rebuilds it. App startup also refreshes the managed payload after an update. + +The downloaded `subminer` and `subminer.cmd` release assets use the same private runtime flow. Older launcher scripts that were installed before this change cannot update their own code retroactively and still need system Bun until the app migrates them at startup or you download a current wrapper. ::: tip Windows users On Windows, the recommended way to launch playback is the **SubMiner mpv** shortcut created during first-run setup - double-click it, drag a file onto it, or run `SubMiner.exe --launch-mpv` from a terminal. See [Windows mpv Shortcut](/usage#windows-mpv-shortcut) for details. diff --git a/docs-site/usage.md b/docs-site/usage.md index 41caf141..1d93fceb 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -12,7 +12,7 @@ On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup That is the whole setup. The `subminer` launcher starts mpv, opens the IPC socket, and brings up the overlay. -If you installed `subminer` from the packaged app's setup window, it uses the app's bundled Bun runtime. You only need the wrapper directory on your terminal `PATH`. The standalone release launcher, `make install`, and the AUR package still require system Bun. +Every current launcher wrapper uses the Bun runtime included with the SubMiner app. This includes setup installs, release downloads, `make install`, and the AUR package. You only need the wrapper directory on your terminal `PATH`. Building SubMiner from source still requires Bun on the development machine. > [!IMPORTANT] > SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 632b2c6b..e4320e44 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -31,6 +31,9 @@ `bun run test:fast` `bun run test:env` `bun run build` + Confirm `dist/launcher` contains only `subminer`, `subminer.cmd`, + `subminer.js`, `prepare.cjs`, and `version`. Release CI smoke-tests + `subminer.js` with Bun, then publishes both wrapper files and their checksums. Tagged CI runs `bun scripts/package-bun-source.mjs` and must publish `bun-v-source.tar.gz` plus its `.sha256` file. The script fails if Bun's CMake dependency pins, WebKit pin, source checksums, patch @@ -59,6 +62,8 @@ `bun run test:fast` `bun run test:env` `bun run build` + Confirm both launcher wrappers and their checksums are included in the + prerelease assets. Prerelease CI also assembles and publishes the pinned Bun corresponding source archive. A missing source repository or license file fails the release instead of publishing only the executable. @@ -105,6 +110,7 @@ Notes: - AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. - Release and prerelease workflows upload updater metadata (`latest*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled. +- Release and prerelease workflows publish `subminer` for POSIX systems and `subminer.cmd` for Windows. Both locate a packaged app and use its private Bun runtime. Keep the corresponding-source archive named `bun-v1.3.5-source.tar.gz`. - macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS `SubMiner--mac.zip`, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer. - macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks. - Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index d57299bb..cdd3fc79 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -15,7 +15,7 @@ SubMiner runs as three cooperating runtimes: The desktop app keeps `src/main.ts` as composition root and pushes behavior into small runtime/domain modules. -Packaged apps include a private Bun runtime. First-run setup can install a managed `subminer` wrapper that invokes it by absolute path. macOS wrappers reference app resources. Windows stages a versioned private Bun copy under `%LOCALAPPDATA%\SubMiner\launcher-runtime` so app updates can replace the bundle while a launcher is running; its wrapper still references the bundled launcher script. Linux stages a persistent runtime and launcher copy under `${XDG_DATA_HOME:-~/.local/share}/SubMiner/launcher` so an AppImage launcher survives after the AppImage exits. Desktop startup refreshes managed payloads after an app version change. Standalone launcher assets and development keep using system Bun. +Packaged apps include a private Bun runtime. Setup and release assets provide bootstrap wrappers generated from `src/main/runtime/*-launcher-bootstrap.ts`. macOS runs Bun and the CLI from app resources. Windows stages a versioned private Bun copy under `%LOCALAPPDATA%\SubMiner\launcher-runtime/` so a running launcher does not lock the updater-owned app executable. Linux stages Bun, the matching CLI, and licenses under `${XDG_DATA_HOME:-~/.local/share}/SubMiner/launcher`. Its steady-state path performs one app `stat` and starts the cache without Electron. A missing cache or changed app fingerprint runs `launcher/prepare.cjs` through Electron's Node mode to refresh it. Desktop startup migrates recognized writable legacy JavaScript launchers and refreshes managed payloads after app changes. Development commands still use system Bun. ## Read Next diff --git a/docs/architecture/layering.md b/docs/architecture/layering.md index ef7e1440..794f3c3e 100644 --- a/docs/architecture/layering.md +++ b/docs/architecture/layering.md @@ -23,7 +23,7 @@ Renderer, launcher, plugin, and stats each keep their own local layering and sho - Keep side effects explicit and close to composition boundaries. - Put reusable business logic in focused services, not in top-level lifecycle files. - Keep renderer concerns in `src/renderer/`; avoid leaking DOM behavior into main-process code. -- Treat `launcher/*.ts` as source of truth for the launcher. Never hand-edit `dist/launcher/subminer`. +- Treat `launcher/*.ts` and the runtime bootstrap generators as source of truth for launcher behavior. `scripts/build-launcher.ts` creates `dist/launcher`; never hand-edit those artifacts. ## Smells diff --git a/launcher/commands/update-command.test.ts b/launcher/commands/update-command.test.ts index ff27784c..8f358e69 100644 --- a/launcher/commands/update-command.test.ts +++ b/launcher/commands/update-command.test.ts @@ -1,5 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { runUpdateCommand } from './update-command'; import type { LauncherCommandContext } from './context'; @@ -62,6 +66,24 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as ]); }); +test('runUpdateCommand sends symlinked AUR installs to the package helper without network access', async () => { + const calls: string[] = []; + const handled = await runUpdateCommand(makeContext({ appPath: '/usr/bin/SubMiner.AppImage' }), { + resolveRealPath: () => '/opt/SubMiner/SubMiner.AppImage', + runDirectReleaseUpdate: async () => { + throw new Error('must not check GitHub releases for an AUR install'); + }, + log: (level, _configured, message) => { + calls.push(`${level}:${message}`); + }, + }); + + assert.equal(handled, true); + assert.deepEqual(calls, [ + 'warn:SubMiner is installed through subminer-bin. Update it with your AUR helper, for example: yay -S subminer-bin.', + ]); +}); + test('runUpdateCommand skips Linux asset replacement when release is not newer', async () => { const calls: string[] = []; const originalFetch = globalThis.fetch; @@ -118,6 +140,65 @@ test('runUpdateCommand skips Linux asset replacement when release is not newer', } }); +test('Linux update does not replace the launcher after an AppImage hash failure', async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-update-order-')); + const appImagePath = path.join(workspace, 'SubMiner.AppImage'); + const launcherPath = path.join(workspace, 'subminer'); + fs.writeFileSync(appImagePath, 'old app'); + fs.writeFileSync(launcherPath, '#!/bin/sh\n# SubMiner launcher\n'); + + const fetched: string[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = input instanceof Request ? input.url : String(input); + fetched.push(url); + if (url.endsWith('/releases')) { + return Response.json([ + { + tag_name: 'v999.0.0', + prerelease: false, + draft: false, + assets: [ + { + name: 'SHA256SUMS.txt', + browser_download_url: 'https://example.test/SHA256SUMS.txt', + }, + { + name: 'SubMiner.AppImage', + browser_download_url: 'https://example.test/SubMiner.AppImage', + }, + { name: 'subminer', browser_download_url: 'https://example.test/subminer' }, + ], + }, + ]); + } + if (url.endsWith('/SHA256SUMS.txt')) { + return new Response( + `${createHash('sha256').update('expected app').digest('hex')} SubMiner.AppImage\n${createHash('sha256').update('new launcher').digest('hex')} subminer\n`, + ); + } + if (url.endsWith('/SubMiner.AppImage')) { + return new Response('corrupt app'); + } + throw new Error(`launcher asset should not be fetched: ${url}`); + }) as typeof globalThis.fetch; + + try { + const handled = await runUpdateCommand( + makeContext({ appPath: appImagePath, scriptPath: launcherPath }), + { readMainConfig: () => null, log: () => {} }, + ); + + assert.equal(handled, true); + assert.equal(fs.readFileSync(appImagePath, 'utf8'), 'old app'); + assert.equal(fs.readFileSync(launcherPath, 'utf8'), '#!/bin/sh\n# SubMiner launcher\n'); + assert.equal(fetched.includes('https://example.test/subminer'), false); + } finally { + globalThis.fetch = originalFetch; + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + test('runUpdateCommand keeps app-mediated update path on non-Linux', async () => { const calls: string[] = []; diff --git a/launcher/commands/update-command.ts b/launcher/commands/update-command.ts index 45020fb6..5fbf6c46 100644 --- a/launcher/commands/update-command.ts +++ b/launcher/commands/update-command.ts @@ -58,6 +58,7 @@ type UpdateCommandDeps = { ) => { status: number; stdout: string; stderr: string; error?: Error }; waitForUpdateResponse: (responsePath: string) => Promise; removeDir: (targetPath: string) => void; + resolveRealPath: (targetPath: string) => string; runDirectReleaseUpdate: ( request: DirectReleaseUpdateRequest, ) => Promise; @@ -98,30 +99,36 @@ async function runDirectReleaseUpdate( : new Map(); const downloadAsset = (url: string) => fetchReleaseAssetBuffer(fetchForUpdater, url); - const [appImage, launcher, supportAssets] = await Promise.all([ - updateAppImageFromRelease({ + const appImage = await updateAppImageFromRelease({ + release, + sha256Sums, + appImagePath: request.appPath, + downloadAsset, + }); + let launcher: DirectReleaseUpdateResult['launcher']; + if (appImage.status !== 'updated') { + launcher = { + status: 'skipped', + message: 'Launcher update requires a successful AppImage update first.', + }; + } else if (process.env.SUBMINER_MANAGED_LAUNCHER === '1') { + launcher = { + status: 'skipped', + message: 'This launcher is updated with the SubMiner app.', + }; + } else { + launcher = await updateLauncherFromRelease({ release, sha256Sums, - appImagePath: request.appPath, + launcherPath: request.launcherPath, downloadAsset, - }), - process.env.SUBMINER_MANAGED_LAUNCHER === '1' - ? Promise.resolve({ - status: 'skipped', - message: 'This launcher is updated with the SubMiner app.', - }) - : updateLauncherFromRelease({ - release, - sha256Sums, - launcherPath: request.launcherPath, - downloadAsset, - }), - updateSupportAssetsFromRelease({ - release, - sha256Sums, - downloadAsset, - }), - ]); + }); + } + const supportAssets = await updateSupportAssetsFromRelease({ + release, + sha256Sums, + downloadAsset, + }); return { appImage, launcher, supportAssets }; } @@ -178,6 +185,13 @@ const defaultDeps: UpdateCommandDeps = { removeDir: (targetPath) => { fs.rmSync(targetPath, { recursive: true, force: true }); }, + resolveRealPath: (targetPath) => { + try { + return fs.realpathSync(targetPath); + } catch { + return targetPath; + } + }, runDirectReleaseUpdate, readMainConfig: readLauncherMainConfigObject, log: launcherLog, @@ -194,12 +208,20 @@ export async function runUpdateCommand( } if (context.processAdapter.platform() === 'linux') { + const logLevel = args.logLevel ?? 'warn'; + if (resolvedDeps.resolveRealPath(appPath) === '/opt/SubMiner/SubMiner.AppImage') { + resolvedDeps.log( + 'warn', + logLevel, + 'SubMiner is installed through subminer-bin. Update it with your AUR helper, for example: yay -S subminer-bin.', + ); + return true; + } const result = await resolvedDeps.runDirectReleaseUpdate({ appPath, launcherPath: scriptPath, channel: readUpdateChannel(resolvedDeps.readMainConfig()), }); - const logLevel = args.logLevel ?? 'warn'; logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps); logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps); for (const supportResult of result.supportAssets) { diff --git a/package.json b/package.json index 1b67f725..e8e8e159 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "compare-yomitan-api:electron": "bun run build:yomitan && bun build scripts/compare-yomitan-api.ts --format=cjs --target=node --outfile dist/scripts/compare-yomitan-api.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/compare-yomitan-api.js", "build:yomitan": "bun scripts/build-yomitan.mjs", "build:assets": "bun scripts/prepare-build-assets.mjs", - "build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer", + "build:launcher": "bun run scripts/build-launcher.ts", "build:stats": "cd stats && bun run build", "dev:stats": "cd stats && bun run dev", "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets", @@ -259,8 +259,15 @@ "to": "plugin/subminer.conf" }, { - "from": "dist/launcher/subminer", - "to": "launcher/subminer" + "from": "dist/launcher", + "to": "launcher", + "filter": [ + "subminer", + "subminer.cmd", + "subminer.js", + "prepare.cjs", + "version" + ] }, { "from": "CHANGELOG.md", diff --git a/packaging/aur/subminer-bin/.SRCINFO b/packaging/aur/subminer-bin/.SRCINFO index 5f734785..b28ffcc9 100644 --- a/packaging/aur/subminer-bin/.SRCINFO +++ b/packaging/aur/subminer-bin/.SRCINFO @@ -5,7 +5,6 @@ pkgbase = subminer-bin url = https://github.com/ksyasuda/SubMiner arch = x86_64 license = GPL-3.0-or-later - depends = bun depends = fuse2 depends = glibc depends = mpv diff --git a/packaging/aur/subminer-bin/PKGBUILD b/packaging/aur/subminer-bin/PKGBUILD index cbe87dd1..bba333f8 100644 --- a/packaging/aur/subminer-bin/PKGBUILD +++ b/packaging/aur/subminer-bin/PKGBUILD @@ -9,7 +9,6 @@ url='https://github.com/ksyasuda/SubMiner' license=('GPL-3.0-or-later') options=('!strip' '!debug') depends=( - 'bun' 'fuse2' 'glibc' 'mpv' diff --git a/scripts/build-changelog.test.ts b/scripts/build-changelog.test.ts index 764bbffd..8412f3a4 100644 --- a/scripts/build-changelog.test.ts +++ b/scripts/build-changelog.test.ts @@ -620,6 +620,8 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./); assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./); assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); + assert.match(prereleaseNotes, /Windows `subminer\.cmd` launcher/); + assert.match(prereleaseNotes, /Both launcher downloads use Bun included with the SubMiner app/); } finally { fs.rmSync(workspace, { recursive: true, force: true }); } diff --git a/scripts/build-changelog.ts b/scripts/build-changelog.ts index 48d5cee0..f0f846be 100644 --- a/scripts/build-changelog.ts +++ b/scripts/build-changelog.ts @@ -977,9 +977,9 @@ function renderReleaseNotes( '- Linux: `SubMiner.AppImage`', '- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`', '- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`', - '- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher', + '- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher', '', - "The launcher installed by desktop setup uses the app's bundled Bun runtime. Only the separately downloaded `subminer` script requires Bun installed on `PATH`.", + 'Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.', '', ].join('\n'); } diff --git a/scripts/build-launcher.ts b/scripts/build-launcher.ts new file mode 100644 index 00000000..ff868a85 --- /dev/null +++ b/scripts/build-launcher.ts @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import packageJson from '../package.json'; +import { posixLauncherBootstrapContent } from '../src/main/runtime/posix-launcher-bootstrap'; +import { windowsLauncherBootstrapContent } from '../src/main/runtime/windows-launcher-bootstrap'; + +const outputDirectory = path.join(process.cwd(), 'dist', 'launcher'); + +function bundle(options: { + entrypoint: string; + outfile: string; + target: 'bun' | 'node'; + format?: 'cjs'; + banner?: string; +}): void { + const args = [ + 'build', + options.entrypoint, + `--outfile=${options.outfile}`, + `--target=${options.target}`, + '--packages=bundle', + ]; + if (options.format) args.push(`--format=${options.format}`); + if (options.banner) args.push(`--banner=${options.banner}`); + execFileSync(process.execPath, args, { stdio: 'inherit' }); +} + +fs.mkdirSync(outputDirectory, { recursive: true }); + +bundle({ + entrypoint: path.join(process.cwd(), 'launcher', 'main.ts'), + outfile: path.join(outputDirectory, 'subminer.js'), + target: 'bun', + banner: '#!/usr/bin/env bun', +}); +bundle({ + entrypoint: path.join(process.cwd(), 'src', 'main', 'runtime', 'prepare-launcher-runtime.ts'), + outfile: path.join(outputDirectory, 'prepare.cjs'), + target: 'node', + format: 'cjs', +}); + +const posixLauncherPath = path.join(outputDirectory, 'subminer'); +fs.writeFileSync(posixLauncherPath, posixLauncherBootstrapContent(), { mode: 0o755 }); +fs.chmodSync(posixLauncherPath, 0o755); +fs.writeFileSync( + path.join(outputDirectory, 'subminer.cmd'), + windowsLauncherBootstrapContent(), + 'utf8', +); +fs.writeFileSync(path.join(outputDirectory, 'version'), `${packageJson.version}\n`, 'utf8'); + +console.log(`Built launcher runtime artifacts in ${outputDirectory}`); diff --git a/scripts/update-aur-package.test.ts b/scripts/update-aur-package.test.ts index 70a46799..c1b8187a 100644 --- a/scripts/update-aur-package.test.ts +++ b/scripts/update-aur-package.test.ts @@ -70,6 +70,7 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => { ); assert.match(pkgbuild, /^pkgver=0\.6\.3$/m); + assert.doesNotMatch(pkgbuild, /^\s*'bun'$/m); assert.match( pkgbuild, /^\s*"subminer-\$\{pkgver\}::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v\$\{pkgver\}\/subminer"$/m, @@ -84,6 +85,7 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => { ); assert.match(pkgbuild, /assets\/thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/); assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m); + assert.doesNotMatch(srcinfo, /^\tdepends = bun$/m); assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m); assert.match( srcinfo, diff --git a/scripts/verify-generated-launcher.sh b/scripts/verify-generated-launcher.sh index aab1494d..4ce7c1be 100755 --- a/scripts/verify-generated-launcher.sh +++ b/scripts/verify-generated-launcher.sh @@ -2,23 +2,38 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -LAUNCHER_OUT="$REPO_ROOT/dist/launcher/subminer" +LAUNCHER_DIR="$REPO_ROOT/dist/launcher" +LAUNCHER_OUT="$LAUNCHER_DIR/subminer" +EXPECTED_ARTIFACTS=(prepare.cjs subminer subminer.cmd subminer.js version) if [[ ! -f "$REPO_ROOT/launcher/main.ts" ]]; then echo "[FAIL] launcher source missing: launcher/main.ts" exit 1 fi -if ! grep -Fn -- "--outfile=\"\$(LAUNCHER_OUT)\"" "$REPO_ROOT/Makefile" >/dev/null; then - echo "[FAIL] Makefile build-launcher target is not writing to dist/launcher/subminer" +if ! grep -F -- "bun run build:launcher" "$REPO_ROOT/Makefile" >/dev/null; then + echo "[FAIL] Makefile build-launcher target does not call the canonical package script" exit 1 fi -if [[ ! -f "$LAUNCHER_OUT" ]]; then - echo "[FAIL] generated launcher not found at dist/launcher/subminer" - echo " run: make build-launcher" - exit 1 -fi +for artifact in "${EXPECTED_ARTIFACTS[@]}"; do + if [[ ! -f "$LAUNCHER_DIR/$artifact" ]]; then + echo "[FAIL] generated launcher artifact missing: dist/launcher/$artifact" + echo " run: make build-launcher" + exit 1 + fi +done + +for artifact_path in "$LAUNCHER_DIR"/*; do + artifact="${artifact_path##*/}" + case "$artifact" in + prepare.cjs | subminer | subminer.cmd | subminer.js | version) ;; + *) + echo "[FAIL] dist/launcher contains an unexpected runtime artifact: $artifact" + exit 1 + ;; + esac +done if [[ ! -x "$LAUNCHER_OUT" ]]; then echo "[FAIL] generated launcher is not executable: dist/launcher/subminer" @@ -32,11 +47,11 @@ if [[ -f "$REPO_ROOT/subminer" ]]; then exit 1 fi -if git -C "$REPO_ROOT" ls-files --error-unmatch dist/launcher/subminer >/dev/null 2>&1; then - echo "[FAIL] dist/launcher/subminer is tracked by git; generated artifacts must remain untracked" +if git -C "$REPO_ROOT" ls-files --error-unmatch dist/launcher >/dev/null 2>&1; then + echo "[FAIL] dist/launcher contains tracked files; generated artifacts must remain untracked" exit 1 fi echo "[OK] launcher workflow verified" echo " source: launcher/*.ts" -echo " generated artifact: dist/launcher/subminer" +echo " generated artifacts: ${EXPECTED_ARTIFACTS[*]}" diff --git a/src/main/runtime/command-line-launcher.ts b/src/main/runtime/command-line-launcher.ts index 9bcb706c..eb20eadb 100644 --- a/src/main/runtime/command-line-launcher.ts +++ b/src/main/runtime/command-line-launcher.ts @@ -5,10 +5,8 @@ import { cleanupOldWindowsManagedRuntimes, isManagedLauncher, managedLauncherContent, - managedLauncherPaths, shellQuote, stageManagedLauncher, - windowsManagedRuntimePaths, } from './managed-launcher'; import { accessSyncOf, @@ -174,9 +172,11 @@ export function resolveLauncherResourcePath(options: CommonOptions): string { if (options.launcherResourcePath) return options.launcherResourcePath; const resourcesPath = options.resourcesPath ?? (process as typeof process & { resourcesPath?: string }).resourcesPath; - const packaged = resourcesPath ? platformPath.join(resourcesPath, 'launcher', 'subminer') : null; + const packaged = resourcesPath + ? platformPath.join(resourcesPath, 'launcher', 'subminer.js') + : null; if (packaged && existsSyncOf(options)(packaged)) return packaged; - return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer'); + return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer.js'); } function isWritableDir(candidate: string, options: CommonOptions): boolean { @@ -313,21 +313,10 @@ export async function detectLauncher( message: 'Reinstall the launcher to use the runtime included with SubMiner.', }; } - const payload = - platform === 'linux' - ? managedLauncherPaths(options) - : { - bunPath: - platform === 'win32' - ? windowsManagedRuntimePaths(options).bunPath - : options.bundledBunPath, - scriptPath: launcherResourcePath, - }; if ( content !== managedLauncherContent({ platform, - ...payload, appPath: envOf(options).APPIMAGE ?? appExePath, }) ) { @@ -420,7 +409,7 @@ export async function installLauncher( message: bun.message ?? 'The included launcher runtime failed to start.', }; try { - const payload = stageManagedLauncher({ + stageManagedLauncher({ ...options, bundledBunPath: options.bundledBunPath, launcherResourcePath, @@ -431,7 +420,6 @@ export async function installLauncher( target.installPath, managedLauncherContent({ platform, - ...payload, appPath: envOf(options).APPIMAGE ?? options.appExePath ?? process.execPath, }), ); @@ -577,7 +565,23 @@ export async function refreshManagedCommandLineLauncher( for (const candidate of candidates) { if (!existsSyncOf(options)(candidate)) continue; const existing = String(readFile(candidate, 'utf8')); - if (!isManagedLauncher(existing)) continue; + const legacy = + (existing.startsWith('#!/usr/bin/env bun\n') && + (existing.includes('SubMiner launcher') || + existing.includes('Launch MPV with SubMiner'))) || + (platform === 'win32' && + existing === + windowsShimContent( + options.appExePath ?? process.execPath, + resolveLauncherResourcePath(options).replace(/subminer\.js$/, 'subminer'), + )); + if (!isManagedLauncher(existing) && !legacy) continue; + if (!isWritableDir(pathModuleFor(platform).dirname(candidate), options)) continue; + try { + accessSyncOf(options)(candidate, fs.constants.W_OK); + } catch { + continue; + } payload ??= stageManagedLauncher({ ...options, bundledBunPath: options.bundledBunPath, @@ -585,7 +589,6 @@ export async function refreshManagedCommandLineLauncher( }); const content = managedLauncherContent({ platform, - ...payload, appPath: envOf(options).APPIMAGE ?? options.appExePath ?? process.execPath, }); if (existing !== content) (options.writeFileSync ?? fs.writeFileSync)(candidate, content); diff --git a/src/main/runtime/managed-launcher.test.ts b/src/main/runtime/managed-launcher.test.ts index 114b5bdd..6a7aa00c 100644 --- a/src/main/runtime/managed-launcher.test.ts +++ b/src/main/runtime/managed-launcher.test.ts @@ -59,10 +59,16 @@ test('packaged POSIX launcher works without Bun on PATH and survives AppImage un 'console.log(JSON.stringify({args:process.argv.slice(2),app:process.env.SUBMINER_BINARY_PATH,managed:process.env.SUBMINER_MANAGED_LAUNCHER}));', ); const appPath = path.join(root, 'SubMiner.AppImage'); + fs.writeFileSync(appPath, '#!/bin/sh\nexit 73\n', { mode: 0o755 }); const options = { platform: process.platform, homeDir: path.join(root, 'home'), - env: { PATH: bin, XDG_DATA_HOME: path.join(root, 'data'), APPIMAGE: appPath }, + env: { + HOME: path.join(root, 'home'), + PATH: bin, + XDG_DATA_HOME: path.join(root, 'data'), + APPIMAGE: appPath, + }, appExePath: path.join(resources, 'SubMiner'), appVersion: '1.0.0', bundledBunPath, @@ -85,13 +91,20 @@ test('packaged POSIX launcher works without Bun on PATH and survives AppImage un test('setup can install into a new user bin despite an empty GUI PATH', async (t) => { if (process.platform === 'win32') return; const root = workspace(t); - const script = path.join(root, 'launcher'); + const appPath = path.join(root, 'SubMiner.app', 'Contents', 'MacOS', 'SubMiner'); + const resources = path.join(root, 'SubMiner.app', 'Contents', 'Resources'); + fs.mkdirSync(path.dirname(appPath), { recursive: true }); + fs.mkdirSync(path.join(resources, 'bun'), { recursive: true }); + fs.mkdirSync(path.join(resources, 'launcher'), { recursive: true }); + fs.writeFileSync(appPath, '#!/bin/sh\nexit 73\n', { mode: 0o755 }); + fs.symlinkSync(process.execPath, path.join(resources, 'bun', 'bun')); + const script = path.join(resources, 'launcher', 'subminer.js'); fs.writeFileSync(script, 'console.log("help");'); const snapshot = await installLauncher({ platform: 'darwin', homeDir: root, env: { PATH: '' }, - appExePath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', + appExePath: appPath, bundledBunPath: process.execPath, launcherResourcePath: script, }); @@ -105,42 +118,58 @@ test('setup can install into a new user bin despite an empty GUI PATH', async (t assert.equal(result.status, 0, result.stderr); }); -test('app upgrades refresh Linux managed payloads and leave standalone launchers alone', async (t) => { +test('app upgrades refresh payloads, migrate legacy Bun launchers, and preserve custom scripts', async (t) => { if (process.platform !== 'linux') return; const root = workspace(t); const bin = path.join(root, 'bin'); fs.mkdirSync(bin, { recursive: true }); const script = path.join(root, 'resource'); fs.writeFileSync(script, 'console.log("old");'); + const appPath = path.join(root, 'SubMiner.AppImage'); + fs.writeFileSync(appPath, '#!/bin/sh\nexit 73\n', { mode: 0o755 }); const options = { platform: process.platform, homeDir: root, - env: { PATH: bin }, + env: { HOME: root, PATH: bin }, appVersion: '1', - appExePath: '/apps/SubMiner.AppImage', + appExePath: appPath, bundledBunPath: process.execPath, launcherResourcePath: script, }; assert.equal((await installLauncher(options)).status, 'ready'); fs.writeFileSync(script, 'console.log("new");'); - await refreshManagedCommandLineLauncher({ ...options, env: { PATH: '' }, appVersion: '2' }); + await refreshManagedCommandLineLauncher({ + ...options, + env: { HOME: root, PATH: '' }, + appVersion: '2', + }); const payload = managedLauncherPaths(options); assert.equal(fs.readFileSync(payload.scriptPath, 'utf8'), 'console.log("new");'); - fs.writeFileSync(path.join(bin, 'subminer'), '#!/bin/sh\necho standalone\n'); + fs.writeFileSync(path.join(bin, 'subminer'), '#!/usr/bin/env bun\n// SubMiner launcher\n'); await refreshManagedCommandLineLauncher({ ...options, appVersion: '3' }); - assert.equal(fs.readFileSync(payload.versionPath, 'utf8'), '2'); + assert.match(fs.readFileSync(path.join(bin, 'subminer'), 'utf8'), /SubMiner managed launcher/); + fs.writeFileSync(path.join(bin, 'subminer'), '#!/bin/sh\necho standalone\n'); + await refreshManagedCommandLineLauncher({ ...options, appVersion: '4' }); + assert.equal(fs.readFileSync(payload.versionPath, 'utf8'), '3'); }); -test('Windows wrapper uses quoted absolute Bun and disables delayed expansion', () => { +test('Windows wrapper discovers the configured app and its versioned private runtime', () => { const content = managedLauncherContent({ platform: 'win32', - bunPath: 'C:\\Apps & Tools\\100%\\bun.exe', - scriptPath: 'C:\\Apps & Tools\\subminer', - appPath: 'C:\\Apps!\\SubMiner.exe', + appPath: 'C:\\Apps 100% !\\SubMiner.exe', }); assert.ok(content.includes(MANAGED_LAUNCHER_MARKER)); assert.ok(content.includes('setlocal DisableDelayedExpansion')); - assert.ok(content.includes('"C:\\Apps & Tools\\100%%\\bun.exe" "C:\\Apps & Tools\\subminer" %*')); + assert.ok(content.includes('set "SUBMINER_BINARY_PATH=C:\\Apps 100%% !\\SubMiner.exe"')); + assert.ok(content.includes('%SUBMINER_RESOURCES_PATH%\\launcher\\version')); + assert.ok( + content.includes( + 'set "SUBMINER_BUN_PATH=%LOCALAPPDATA%\\SubMiner\\launcher-runtime\\%SUBMINER_APP_VERSION%\\bun.exe"', + ), + ); + assert.ok( + content.includes('"%SUBMINER_BUN_PATH%" "%SUBMINER_RESOURCES_PATH%\\launcher\\subminer.js" %*'), + ); assert.ok(content.includes('exit /b %errorlevel%')); }); @@ -178,14 +207,20 @@ test('Windows managed runtime path is absolute, versioned, and injectable', () = test('Windows managed launcher forwards arguments without a system Bun', async (t) => { if (process.platform !== 'win32') return; const root = workspace(t); - const script = path.join(root, 'script.js'); + const appDirectory = path.join(root, 'Installed App'); + const appPath = path.join(appDirectory, 'SubMiner.exe'); + const launcherDirectory = path.join(appDirectory, 'resources', 'launcher'); + const script = path.join(launcherDirectory, 'subminer.js'); + fs.mkdirSync(launcherDirectory, { recursive: true }); + fs.copyFileSync(process.execPath, appPath); fs.writeFileSync(script, 'console.log(JSON.stringify(process.argv.slice(2)));'); + fs.writeFileSync(path.join(launcherDirectory, 'version'), '1.0.0'); const options = { platform: process.platform, - env: { ...process.env, PATH: '' }, + env: { ...process.env, PATH: '', LOCALAPPDATA: root }, bundledBunPath: process.execPath, launcherResourcePath: script, - appExePath: path.join(root, 'SubMiner.exe'), + appExePath: appPath, appVersion: '1.0.0', localAppData: root, getUserPath: () => '', diff --git a/src/main/runtime/managed-launcher.ts b/src/main/runtime/managed-launcher.ts index 67bab230..0f2ece8f 100644 --- a/src/main/runtime/managed-launcher.ts +++ b/src/main/runtime/managed-launcher.ts @@ -1,4 +1,7 @@ import { randomUUID } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { windowsLauncherBootstrapContent } from './windows-launcher-bootstrap'; +import { MANAGED_LAUNCHER_MARKER, posixLauncherBootstrapContent } from './posix-launcher-bootstrap'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -11,7 +14,7 @@ import { type WindowsPathOptions, } from './command-line-launcher-deps'; -export const MANAGED_LAUNCHER_MARKER = 'SubMiner managed launcher (bundled runtime)'; +export { MANAGED_LAUNCHER_MARKER, shellQuote } from './posix-launcher-bootstrap'; export function isManagedLauncher(content: string): boolean { const lines = content.split(/\r?\n/, 3); @@ -21,43 +24,12 @@ export function isManagedLauncher(content: string): boolean { ); } -export function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} - -function windowsLiteral(value: string): string { - if (/["\r\n]/.test(value)) throw new Error('Launcher paths cannot contain quotes or newlines.'); - return value.replaceAll('%', '%%'); -} - export function managedLauncherContent(options: { platform: NodeJS.Platform; - bunPath: string; - scriptPath: string; appPath: string; }): string { - if (options.platform === 'win32') { - return [ - '@echo off', - `rem ${MANAGED_LAUNCHER_MARKER}`, - 'setlocal DisableDelayedExpansion', - 'set "SUBMINER_MANAGED_LAUNCHER=1"', - 'set "SUBMINER_LAUNCHER_PATH=%~f0"', - `set "SUBMINER_BINARY_PATH=${windowsLiteral(options.appPath)}"`, - `"${windowsLiteral(options.bunPath)}" "${windowsLiteral(options.scriptPath)}" %*`, - 'exit /b %errorlevel%', - '', - ].join('\r\n'); - } - return [ - '#!/bin/sh', - `# ${MANAGED_LAUNCHER_MARKER}`, - 'export SUBMINER_MANAGED_LAUNCHER=1', - 'export SUBMINER_LAUNCHER_PATH="$0"', - `export SUBMINER_BINARY_PATH=${shellQuote(options.appPath)}`, - `exec ${shellQuote(options.bunPath)} ${shellQuote(options.scriptPath)} "$@"`, - '', - ].join('\n'); + if (options.platform === 'win32') return windowsLauncherBootstrapContent(options.appPath); + return posixLauncherBootstrapContent(options.appPath); } export function managedLauncherPaths(options: CommonOptions) { @@ -65,7 +37,7 @@ export function managedLauncherPaths(options: CommonOptions) { const platformPath = pathModuleFor(platform); const env = envOf(options); const home = options.homeDir ?? os.homedir(); - const dataHome = env.XDG_DATA_HOME?.trim(); + const dataHome = env.XDG_DATA_HOME; const directory = platformPath.join( dataHome && platformPath.isAbsolute(dataHome) ? dataHome @@ -78,6 +50,8 @@ export function managedLauncherPaths(options: CommonOptions) { bunPath: platformPath.join(directory, 'bun'), scriptPath: platformPath.join(directory, 'subminer'), versionPath: platformPath.join(directory, 'version'), + fingerprintPath: platformPath.join(directory, 'fingerprint'), + appPathFile: platformPath.join(directory, 'app-path'), }; } @@ -203,10 +177,21 @@ export function stageManagedLauncher( const mkdir = options.mkdirSync ?? fs.mkdirSync; const chmod = options.chmodSync ?? fs.chmodSync; const version = options.appVersion ?? 'development'; + const appPath = envOf(options).APPIMAGE ?? options.appExePath; + const fingerprint = appPath + ? execFileSync('stat', ['-Lc', '%d:%i:%s:%y:%z', '--', appPath], { + encoding: 'utf8', + env: { ...envOf(options), PATH: `/usr/bin:/bin:${envOf(options).PATH ?? ''}` }, + }).trim() + : ''; if ( !options.force && exists(paths.versionPath) && read(paths.versionPath, 'utf8') === version && + exists(paths.fingerprintPath) && + read(paths.fingerprintPath, 'utf8') === `${fingerprint}\n` && + exists(paths.appPathFile) && + read(paths.appPathFile, 'utf8') === `${appPath ?? ''}\n` && exists(paths.bunPath) && exists(paths.scriptPath) ) @@ -222,7 +207,9 @@ export function stageManagedLauncher( if (exists(notices)) fs.cpSync(notices, path.join(paths.directory, 'licenses'), { recursive: true }); write(path.join(staging, 'version'), version); - for (const name of ['bun', 'subminer', 'version']) { + write(path.join(staging, 'app-path'), `${appPath ?? ''}\n`); + write(path.join(staging, 'fingerprint'), `${fingerprint}\n`); + for (const name of ['bun', 'subminer', 'version', 'app-path', 'fingerprint']) { fs.renameSync(path.join(staging, name), path.join(paths.directory, name)); } } finally { diff --git a/src/main/runtime/posix-launcher-bootstrap.test.ts b/src/main/runtime/posix-launcher-bootstrap.test.ts new file mode 100644 index 00000000..c1793e1d --- /dev/null +++ b/src/main/runtime/posix-launcher-bootstrap.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { posixLauncherBootstrapContent, shellQuote } from './posix-launcher-bootstrap'; + +test('downloaded launcher prepares once, survives unmount, and refreshes after app replacement', (t) => { + if (process.platform !== 'linux') return; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "subminer bootstrap's ")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const appPath = path.join(root, 'SubMiner.AppImage'); + const resources = path.join(root, 'mount', 'resources'); + fs.mkdirSync(path.join(resources, 'launcher'), { recursive: true }); + fs.mkdirSync(path.join(resources, 'bun', 'licenses'), { recursive: true }); + fs.symlinkSync(process.execPath, path.join(resources, 'bun', 'bun')); + fs.writeFileSync(path.join(resources, 'bun', 'licenses', 'notice'), 'Bun notice'); + fs.writeFileSync(path.join(resources, 'launcher', 'version'), '1.0.0\n'); + fs.writeFileSync( + path.join(resources, 'launcher', 'prepare.cjs'), + `exports.prepareLauncherRuntime = require(${JSON.stringify(path.join(__dirname, 'prepare-launcher-runtime.ts'))}).prepareLauncherRuntime;`, + ); + const script = (version: number) => + `console.log(JSON.stringify({version:${version},args:process.argv.slice(2)}));`; + fs.writeFileSync(path.join(resources, 'launcher', 'subminer.js'), script(1)); + const prepares = path.join(root, 'preparations'); + const appContent = `#!/bin/sh\necho prepared >> ${shellQuote(prepares)}\nexport APPDIR=${shellQuote(path.dirname(resources))}\nexec ${shellQuote(process.execPath)} "$@"\n`; + fs.writeFileSync(appPath, appContent, { mode: 0o755 }); + const wrapper = path.join(root, 'subminer'); + fs.writeFileSync(wrapper, posixLauncherBootstrapContent(), { mode: 0o755 }); + const env = { HOME: root, PATH: '', SUBMINER_BINARY_PATH: appPath }; + const args = ['space here', "a'b", 'a&b', '$(touch nope)', '日本語']; + const run = (extraEnv = {}) => + spawnSync(wrapper, args, { env: { ...env, ...extraEnv }, encoding: 'utf8' }); + const first = run(); + assert.equal(first.status, 0, first.stderr); + assert.deepEqual(JSON.parse(first.stdout), { version: 1, args }); + const cache = path.join(root, '.local', 'share', 'SubMiner', 'launcher'); + assert.equal(fs.readFileSync(path.join(cache, 'licenses', 'notice'), 'utf8'), 'Bun notice'); + + fs.renameSync(resources, `${resources}.unmounted`); + const warm = run({ SUBMINER_BINARY_PATH: '' }); // Finds the app recorded by preparation. + assert.equal(warm.status, 0, warm.stderr); + assert.deepEqual(JSON.parse(warm.stdout), { version: 1, args }); + assert.equal(fs.readFileSync(prepares, 'utf8'), 'prepared\n'); + fs.renameSync(`${resources}.unmounted`, resources); + + // Same version and path, new inode: manual replacement must still refresh. + fs.writeFileSync(`${appPath}.new`, appContent, { mode: 0o755 }); + fs.renameSync(`${appPath}.new`, appPath); + fs.writeFileSync(path.join(resources, 'launcher', 'subminer.js'), script(2)); + const updated = run(); + assert.equal(updated.status, 0, updated.stderr); + assert.deepEqual(JSON.parse(updated.stdout), { version: 2, args }); + assert.equal(fs.readFileSync(prepares, 'utf8'), 'prepared\nprepared\n'); + + fs.unlinkSync(path.join(cache, 'bun')); + assert.equal(run().status, 0); + assert.equal(fs.readFileSync(prepares, 'utf8'), 'prepared\nprepared\nprepared\n'); + + fs.writeFileSync(appPath, '#!/bin/sh\nexit 42\n', { mode: 0o755 }); + const failed = run(); + assert.equal(failed.status, 42); + assert.equal(failed.stdout, ''); // Never silently execute stale CLI after failed preparation. +}); diff --git a/src/main/runtime/posix-launcher-bootstrap.ts b/src/main/runtime/posix-launcher-bootstrap.ts new file mode 100644 index 00000000..3cde8e6e --- /dev/null +++ b/src/main/runtime/posix-launcher-bootstrap.ts @@ -0,0 +1,56 @@ +export const MANAGED_LAUNCHER_MARKER = 'SubMiner managed launcher (bundled runtime)'; + +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +// Only a missing or stale Linux cache starts Electron in Node mode. Normal +// launches do one stat and execute the cached Bun and matching CLI directly. +export function posixLauncherBootstrapContent(appPath = ''): string { + return `#!/bin/sh +# ${MANAGED_LAUNCHER_MARKER} +export SUBMINER_MANAGED_LAUNCHER=1 +export SUBMINER_LAUNCHER_PATH="$0" +subminer_default_app=${shellQuote(appPath)} +case "\${XDG_DATA_HOME:-}" in + /*) subminer_data="$XDG_DATA_HOME" ;; + *) subminer_data="$HOME/.local/share" ;; +esac +subminer_cache="$subminer_data/SubMiner/launcher" +subminer_saved_app= +if [ -f "$subminer_cache/app-path" ]; then + IFS= read -r subminer_saved_app < "$subminer_cache/app-path" || : +fi +subminer_app= +for subminer_candidate in "\${SUBMINER_APPIMAGE_PATH:-}" "\${SUBMINER_BINARY_PATH:-}" "$subminer_default_app" "$subminer_saved_app" "$HOME/.local/bin/SubMiner.AppImage" /opt/SubMiner/SubMiner.AppImage /Applications/SubMiner.app/Contents/MacOS/SubMiner "$HOME/Applications/SubMiner.app/Contents/MacOS/SubMiner"; do + if [ -n "$subminer_candidate" ] && [ -x "$subminer_candidate" ]; then + subminer_app="$subminer_candidate" + break + fi +done +if [ -z "$subminer_app" ]; then + echo 'SubMiner app not found. Install the app or set SUBMINER_BINARY_PATH to its executable.' >&2 + exit 1 +fi +export SUBMINER_BINARY_PATH="$subminer_app" +case "$subminer_app" in + */Contents/MacOS/*) + subminer_resources="\${subminer_app%/MacOS/*}/Resources" + if [ ! -x "$subminer_resources/bun/bun" ] || [ ! -f "$subminer_resources/launcher/subminer.js" ]; then + echo 'This launcher requires a SubMiner app with the included Bun runtime. Update SubMiner.' >&2 + exit 1 + fi + exec "$subminer_resources/bun/bun" "$subminer_resources/launcher/subminer.js" "$@" + ;; +esac +subminer_fingerprint=$(PATH="/usr/bin:/bin:$PATH" stat -Lc '%d:%i:%s:%y:%z' -- "$subminer_app") || exit 1 +subminer_cached_fingerprint= +if [ -f "$subminer_cache/fingerprint" ]; then + IFS= read -r subminer_cached_fingerprint < "$subminer_cache/fingerprint" || : +fi +if [ "$subminer_app" != "$subminer_saved_app" ] || [ "$subminer_fingerprint" != "$subminer_cached_fingerprint" ] || [ ! -x "$subminer_cache/bun" ] || [ ! -f "$subminer_cache/subminer" ]; then + PATH="/usr/bin:/bin:$PATH" ELECTRON_RUN_AS_NODE=1 "$subminer_app" -e 'const p=require("node:path"); const r=process.env.APPDIR ? p.join(process.env.APPDIR,"resources") : p.join(p.dirname(process.execPath),"resources"); try { require(p.join(r,"launcher/prepare.cjs")).prepareLauncherRuntime({appPath:process.env.SUBMINER_BINARY_PATH,resourcesPath:r}); } catch(e) { console.error("Cannot prepare SubMiner launcher. Update or reinstall the SubMiner app.",e.message); process.exit(1); }' || exit $? +fi +exec "$subminer_cache/bun" "$subminer_cache/subminer" "$@" +`; +} diff --git a/src/main/runtime/prepare-launcher-runtime.ts b/src/main/runtime/prepare-launcher-runtime.ts new file mode 100644 index 00000000..40de1aa2 --- /dev/null +++ b/src/main/runtime/prepare-launcher-runtime.ts @@ -0,0 +1,24 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { cleanupOldWindowsManagedRuntimes, stageManagedLauncher } from './managed-launcher'; + +// Bundled separately as Node-compatible code so AppImages can prepare their +// runtime without starting Electron's GUI, single-instance lock, or settings. +export function prepareLauncherRuntime(options: { appPath: string; resourcesPath: string }) { + const appVersion = fs + .readFileSync(path.join(options.resourcesPath, 'launcher', 'version'), 'utf8') + .trim(); + const payload = stageManagedLauncher({ + appExePath: options.appPath, + appVersion, + bundledBunPath: path.join( + options.resourcesPath, + 'bun', + process.platform === 'win32' ? 'bun.exe' : 'bun', + ), + launcherResourcePath: path.join(options.resourcesPath, 'launcher', 'subminer.js'), + force: process.platform === 'linux', + }); + if (process.platform === 'win32') cleanupOldWindowsManagedRuntimes({ appVersion }); + return payload; +} diff --git a/src/main/runtime/update/appimage-updater.test.ts b/src/main/runtime/update/appimage-updater.test.ts index 67a323ff..7c190036 100644 --- a/src/main/runtime/update/appimage-updater.test.ts +++ b/src/main/runtime/update/appimage-updater.test.ts @@ -58,7 +58,7 @@ test('updateAppImageFromRelease verifies hash and atomically replaces writable A ]); }); -test('updateAppImageFromRelease reports protected command without replacing non-writable AppImage', async () => { +test('updateAppImageFromRelease reports protected command for a direct non-writable AppImage', async () => { const result = await updateAppImageFromRelease({ release: { tag_name: 'v0.15.0', @@ -67,7 +67,7 @@ test('updateAppImageFromRelease reports protected command without replacing non- assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }], }, sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]), - appImagePath: '/opt/SubMiner/SubMiner.AppImage', + appImagePath: '/usr/local/lib/SubMiner.AppImage', downloadAsset: async () => appImageBytes, fs: { stat: async () => ({ @@ -87,10 +87,50 @@ test('updateAppImageFromRelease reports protected command without replacing non- }); assert.equal(result.status, 'protected'); - assert.equal(result.path, '/opt/SubMiner/SubMiner.AppImage'); + assert.equal(result.path, '/usr/local/lib/SubMiner.AppImage'); assert.match(result.command ?? '', /curl -fSL 'https:\/\/example\.test\/app' -o "\$tmp"/); assert.match(result.command ?? '', /sha256sum -c -/); - assert.match(result.command ?? '', /sudo mv "\$tmp" '\/opt\/SubMiner\/SubMiner\.AppImage'/); + assert.match(result.command ?? '', /sudo mv "\$tmp" '\/usr\/local\/lib\/SubMiner\.AppImage'/); +}); + +test('updateAppImageFromRelease leaves canonical and symlinked AUR AppImages to pacman', async () => { + for (const appImagePath of ['/opt/SubMiner/SubMiner.AppImage', '/usr/bin/SubMiner.AppImage']) { + let accessed = false; + const result = await updateAppImageFromRelease({ + release: { + tag_name: 'v0.15.0', + prerelease: false, + draft: false, + assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }], + }, + sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]), + appImagePath, + downloadAsset: async () => { + throw new Error('must not download package-managed AppImage'); + }, + fs: { + realpath: async () => '/opt/SubMiner/SubMiner.AppImage', + stat: async () => { + throw new Error('must not stat package-managed AppImage'); + }, + access: async () => { + accessed = true; + }, + writeFile: async () => {}, + chmod: async () => {}, + rename: async () => {}, + unlink: async () => {}, + }, + }); + + assert.deepEqual(result, { + status: 'skipped', + path: appImagePath, + message: 'This AppImage is managed by the subminer-bin system package.', + }); + assert.equal(accessed, false); + assert.equal(result.command, undefined); + } }); test('buildProtectedAppImageUpdateCommand quotes inputs and verifies checksum before sudo move', () => { diff --git a/src/main/runtime/update/appimage-updater.ts b/src/main/runtime/update/appimage-updater.ts index 337efc4e..de52acc9 100644 --- a/src/main/runtime/update/appimage-updater.ts +++ b/src/main/runtime/update/appimage-updater.ts @@ -25,6 +25,7 @@ export interface AppImageUpdateResult { } export interface AppImageUpdateFileSystem { + realpath?: (targetPath: string) => Promise; stat: (targetPath: string) => Promise; access: (targetPath: string) => Promise; writeFile: (targetPath: string, data: Buffer) => Promise; @@ -39,6 +40,7 @@ function sha256(data: Buffer): string { function defaultFs(): AppImageUpdateFileSystem { return { + realpath: (targetPath) => fs.promises.realpath(targetPath), stat: (targetPath) => fs.promises.stat(targetPath), access: async (targetPath) => { await fs.promises.access(targetPath, fs.constants.W_OK); @@ -105,6 +107,19 @@ export async function updateAppImageFromRelease(options: { } const fsDeps = options.fs ?? defaultFs(); + let resolvedAppImagePath = options.appImagePath; + try { + resolvedAppImagePath = (await fsDeps.realpath?.(options.appImagePath)) ?? options.appImagePath; + } catch { + // stat below reports a missing or inaccessible path with the existing result shape. + } + if (resolvedAppImagePath === '/opt/SubMiner/SubMiner.AppImage') { + return { + status: 'skipped', + path: options.appImagePath, + message: 'This AppImage is managed by the subminer-bin system package.', + }; + } let stat: StatLike; try { stat = await fsDeps.stat(options.appImagePath); diff --git a/src/main/runtime/update/launcher-updater.test.ts b/src/main/runtime/update/launcher-updater.test.ts index b3f9ce94..adbfaaaa 100644 --- a/src/main/runtime/update/launcher-updater.test.ts +++ b/src/main/runtime/update/launcher-updater.test.ts @@ -5,6 +5,7 @@ import { buildProtectedLauncherUpdateCommand, looksLikeSubminerLauncher, updateLauncherAtPath, + updateLauncherFromRelease, } from './launcher-updater'; const launcherBytes = Buffer.from('#!/usr/bin/env bash\n# SubMiner launcher\nexec SubMiner "$@"\n'); @@ -141,8 +142,6 @@ test('app-managed wrappers are never overwritten by the standalone release scrip readFile: async () => managedLauncherContent({ platform: 'linux', - bunPath: '/private/bun', - scriptPath: '/private/subminer', appPath: '/apps/SubMiner.AppImage', }), access: async () => { @@ -159,3 +158,72 @@ test('app-managed wrappers are never overwritten by the standalone release scrip assert.equal(result.status, 'skipped'); assert.equal(downloaded, false); }); + +test('GUI updates defer recognized standalone launcher migration to app startup', async () => { + let accessed = false; + let downloaded = false; + const result = await updateLauncherAtPath({ + launcherPath: '/home/tester/.local/bin/subminer', + assetUrl: 'https://example.test/subminer', + expectedSha256: launcherHash, + deferRecognizedLauncherUpdate: true, + download: async () => { + downloaded = true; + return launcherBytes; + }, + fs: { + stat: async () => ({ isFile: () => true }), + readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'), + access: async () => { + accessed = true; + }, + writeFile: async () => {}, + chmod: async () => {}, + rename: async () => {}, + unlink: async () => {}, + }, + }); + + assert.deepEqual(result, { + status: 'skipped', + path: '/home/tester/.local/bin/subminer', + message: 'Launcher migration is deferred until the updated SubMiner app starts.', + }); + assert.equal(accessed, false); + assert.equal(downloaded, false); +}); + +test('release launcher updater propagates GUI migration deferral', async () => { + let downloaded = false; + const result = await updateLauncherFromRelease({ + release: { + tag_name: 'v0.15.0', + prerelease: false, + draft: false, + assets: [{ name: 'subminer', browser_download_url: 'https://example.test/subminer' }], + }, + sha256Sums: new Map([['subminer', launcherHash]]), + launcherPath: '/home/tester/.local/bin/subminer', + deferRecognizedLauncherUpdate: true, + exists: () => true, + downloadAsset: async () => { + downloaded = true; + return launcherBytes; + }, + fs: { + stat: async () => ({ isFile: () => true }), + readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'), + access: async () => { + throw new Error('must not check writability before app startup'); + }, + writeFile: async () => {}, + chmod: async () => {}, + rename: async () => {}, + unlink: async () => {}, + }, + }); + + assert.equal(result.status, 'skipped'); + assert.match(result.message ?? '', /deferred until the updated SubMiner app starts/); + assert.equal(downloaded, false); +}); diff --git a/src/main/runtime/update/launcher-updater.ts b/src/main/runtime/update/launcher-updater.ts index f943b535..0be6bf33 100644 --- a/src/main/runtime/update/launcher-updater.ts +++ b/src/main/runtime/update/launcher-updater.ts @@ -83,6 +83,7 @@ export async function updateLauncherAtPath(options: { assetUrl: string; expectedSha256: string; download: () => Promise; + deferRecognizedLauncherUpdate?: boolean; fs?: LauncherUpdateFileSystem; }): Promise { const fsDeps = options.fs ?? defaultFs(); @@ -111,6 +112,13 @@ export async function updateLauncherAtPath(options: { message: 'Existing executable does not look like a SubMiner launcher.', }; } + if (options.deferRecognizedLauncherUpdate) { + return { + status: 'skipped', + path: options.launcherPath, + message: 'Launcher migration is deferred until the updated SubMiner app starts.', + }; + } try { await fsDeps.access(options.launcherPath); @@ -168,7 +176,9 @@ export async function updateLauncherFromRelease(options: { platform?: NodeJS.Platform; homeDir?: string; downloadAsset: (url: string) => Promise; + deferRecognizedLauncherUpdate?: boolean; exists?: (targetPath: string) => boolean; + fs?: LauncherUpdateFileSystem; }): Promise { if (!options.release) return { status: 'missing-asset', message: 'No release found.' }; const asset = findReleaseAsset(options.release, 'subminer'); @@ -192,5 +202,7 @@ export async function updateLauncherFromRelease(options: { assetUrl: asset.browser_download_url, expectedSha256, download: () => options.downloadAsset(asset.browser_download_url), + deferRecognizedLauncherUpdate: options.deferRecognizedLauncherUpdate, + fs: options.fs, }); } diff --git a/src/main/runtime/update/update-service-runtime.ts b/src/main/runtime/update/update-service-runtime.ts index 7ddcd3ac..7334ffd8 100644 --- a/src/main/runtime/update/update-service-runtime.ts +++ b/src/main/runtime/update/update-service-runtime.ts @@ -79,6 +79,7 @@ export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): { sha256Sums: sums, launcherPath, downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url), + deferRecognizedLauncherUpdate: true, }); return runSupportAssetUpdatesForLauncherResult({ launcherResult, diff --git a/src/main/runtime/windows-launcher-bootstrap.test.ts b/src/main/runtime/windows-launcher-bootstrap.test.ts new file mode 100644 index 00000000..6a9f50f9 --- /dev/null +++ b/src/main/runtime/windows-launcher-bootstrap.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { getRunCommand } from './command-line-launcher-deps'; +import { windowsLauncherBootstrapContent } from './windows-launcher-bootstrap'; + +function workspace(t: test.TestContext): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer windows bootstrap ')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +test('generic bootstrap searches supported Windows install locations in order', () => { + const content = windowsLauncherBootstrapContent(); + const override = content.indexOf('if defined SUBMINER_BINARY_PATH goto subminer_app_found'); + const localInstall = content.indexOf('%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe'); + const machineInstall = content.indexOf('%ProgramFiles%\\SubMiner\\SubMiner.exe'); + + assert.ok(override >= 0); + assert.ok(localInstall > override); + assert.ok(machineInstall > localInstall); + assert.match(content, /SubMiner app not found/); +}); + +test('configured app path is an escaped fallback after the environment override', () => { + const configured = 'D:\\Apps & Tools\\SubMiner 100% !\\SubMiner.exe'; + const content = windowsLauncherBootstrapContent(configured); + + assert.ok( + content.indexOf('if defined SUBMINER_BINARY_PATH goto subminer_app_found') < + content.indexOf('if not exist "D:\\Apps & Tools\\SubMiner 100%% !\\SubMiner.exe"'), + ); + assert.match( + content, + /set "SUBMINER_BINARY_PATH=D:\\Apps & Tools\\SubMiner 100%% !\\SubMiner\.exe"/, + ); + assert.throws( + () => windowsLauncherBootstrapContent('C:\\Bad "Install"\\SubMiner.exe'), + /quotes or newlines/, + ); +}); + +test('cached runtime is the fast path and launcher arguments remain opaque to the batch file', () => { + const content = windowsLauncherBootstrapContent(); + const cacheCheck = content.indexOf('if exist "%SUBMINER_BUN_PATH%" goto subminer_run'); + const electronPrepare = content.indexOf('set "ELECTRON_RUN_AS_NODE=1"'); + const run = content.indexOf( + '"%SUBMINER_BUN_PATH%" "%SUBMINER_RESOURCES_PATH%\\launcher\\subminer.js" %*', + ); + + assert.match(content, /^@echo off\r\nrem SubMiner managed launcher \(bundled runtime\)/); + assert.match(content, /setlocal DisableDelayedExpansion/); + assert.ok(cacheCheck >= 0); + assert.ok(electronPrepare > cacheCheck); + assert.ok(run > electronPrepare); + assert.doesNotMatch(content, /powershell/i); + assert.doesNotMatch(content, /^\s*(?:call\s+)?bun(?:\.exe)?(?:\s|$)/im); +}); + +test('preparation failures keep their exit code and never continue to the launcher', () => { + const content = windowsLauncherBootstrapContent(); + + assert.match(content, /set "SUBMINER_PREPARE_EXIT=%errorlevel%"/); + assert.match(content, /if not "%SUBMINER_PREPARE_EXIT%"=="0" exit \/b %SUBMINER_PREPARE_EXIT%/); + assert.match(content, /if not exist "%SUBMINER_BUN_PATH%" goto subminer_prepare_missing/); + assert.match(content, /set "ELECTRON_RUN_AS_NODE="/); +}); + +test('Windows bootstrap prepares once and forwards metacharacter arguments', async (t) => { + if (process.platform !== 'win32') return; + + const root = workspace(t); + const appDirectory = path.join(root, 'Installed & App 100% !'); + const appPath = path.join(appDirectory, 'SubMiner.exe'); + const resourcesPath = path.join(appDirectory, 'resources'); + const launcherDirectory = path.join(resourcesPath, 'launcher'); + const localAppData = path.join(root, 'Local App Data'); + const bootstrapPath = path.join(root, 'subminer.cmd'); + const version = '1.2.3-test'; + const cachedBunPath = path.join(localAppData, 'SubMiner', 'launcher-runtime', version, 'bun.exe'); + fs.mkdirSync(launcherDirectory, { recursive: true }); + fs.copyFileSync(process.execPath, appPath); + fs.writeFileSync(path.join(launcherDirectory, 'version'), version); + fs.writeFileSync( + path.join(launcherDirectory, 'subminer.js'), + 'console.log(JSON.stringify({args:process.argv.slice(2),app:process.env.SUBMINER_BINARY_PATH,resources:process.env.SUBMINER_RESOURCES_PATH,managed:process.env.SUBMINER_MANAGED_LAUNCHER}));', + ); + fs.writeFileSync( + path.join(launcherDirectory, 'prepare.cjs'), + `const fs=require('node:fs');const path=require('node:path');exports.prepareLauncherRuntime=()=>{const target=${JSON.stringify(cachedBunPath)};fs.mkdirSync(path.dirname(target),{recursive:true});fs.copyFileSync(process.execPath,target);};`, + ); + fs.writeFileSync(bootstrapPath, windowsLauncherBootstrapContent(appPath)); + + const args = [ + 'spaces here', + '100%', + 'bang!', + 'a&b', + 'x|y', + '', + 'caret^', + 'say "hi"', + '日本語', + ]; + const env = { ...process.env, PATH: '', Path: '', LOCALAPPDATA: localAppData }; + const first = await getRunCommand({})(bootstrapPath, args, { env }); + assert.equal(first.exitCode, 0, first.stderr); + assert.ok(fs.existsSync(cachedBunPath)); + const firstPayload: unknown = JSON.parse(first.stdout); + assert.deepEqual(firstPayload, { + args, + app: appPath, + resources: resourcesPath, + managed: '1', + }); + + fs.writeFileSync( + path.join(launcherDirectory, 'prepare.cjs'), + "throw new Error('cached launch should not prepare');", + ); + const second = await getRunCommand({})(bootstrapPath, args, { env }); + assert.equal(second.exitCode, 0, second.stderr); + const secondPayload: unknown = JSON.parse(second.stdout); + assert.deepEqual(secondPayload, firstPayload); +}); diff --git a/src/main/runtime/windows-launcher-bootstrap.ts b/src/main/runtime/windows-launcher-bootstrap.ts new file mode 100644 index 00000000..11b74499 --- /dev/null +++ b/src/main/runtime/windows-launcher-bootstrap.ts @@ -0,0 +1,75 @@ +const MANAGED_LAUNCHER_MARKER = 'SubMiner managed launcher (bundled runtime)'; + +function windowsBatchLiteral(value: string): string { + if (/["\r\n]/.test(value)) { + throw new Error('Launcher paths cannot contain quotes or newlines.'); + } + return value.replaceAll('%', '%%'); +} + +function configuredAppCandidate(appPath: string | undefined): string[] { + if (!appPath) return []; + const literal = windowsBatchLiteral(appPath); + return [ + `if not exist "${literal}" goto subminer_check_local_app`, + `set "SUBMINER_BINARY_PATH=${literal}"`, + 'goto subminer_app_found', + ]; +} + +// This command file stays valid across app updates. It locates the current app +// and only starts Electron in Node mode when that version's private Bun is absent. +export function windowsLauncherBootstrapContent(appPath?: string): string { + return [ + '@echo off', + `rem ${MANAGED_LAUNCHER_MARKER}`, + 'setlocal DisableDelayedExpansion', + 'set "SUBMINER_MANAGED_LAUNCHER=1"', + 'set "SUBMINER_LAUNCHER_PATH=%~f0"', + 'if defined SUBMINER_BINARY_PATH goto subminer_app_found', + ...configuredAppCandidate(appPath), + ':subminer_check_local_app', + 'if not defined LOCALAPPDATA goto subminer_check_program_files', + 'if not exist "%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" goto subminer_check_program_files', + 'set "SUBMINER_BINARY_PATH=%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe"', + 'goto subminer_app_found', + ':subminer_check_program_files', + 'if not defined ProgramFiles goto subminer_app_missing', + 'if not exist "%ProgramFiles%\\SubMiner\\SubMiner.exe" goto subminer_app_missing', + 'set "SUBMINER_BINARY_PATH=%ProgramFiles%\\SubMiner\\SubMiner.exe"', + ':subminer_app_found', + 'if not exist "%SUBMINER_BINARY_PATH%" goto subminer_app_missing', + 'if not defined LOCALAPPDATA goto subminer_local_app_data_missing', + 'for %%I in ("%SUBMINER_BINARY_PATH%") do set "SUBMINER_RESOURCES_PATH=%%~dpIresources"', + 'if not exist "%SUBMINER_RESOURCES_PATH%\\launcher\\subminer.js" goto subminer_resources_missing', + 'set "SUBMINER_APP_VERSION="', + 'if not exist "%SUBMINER_RESOURCES_PATH%\\launcher\\version" goto subminer_resources_missing', + 'set /p "SUBMINER_APP_VERSION="<"%SUBMINER_RESOURCES_PATH%\\launcher\\version"', + 'if not defined SUBMINER_APP_VERSION goto subminer_resources_missing', + 'set "SUBMINER_BUN_PATH=%LOCALAPPDATA%\\SubMiner\\launcher-runtime\\%SUBMINER_APP_VERSION%\\bun.exe"', + 'if exist "%SUBMINER_BUN_PATH%" goto subminer_run', + 'if not exist "%SUBMINER_RESOURCES_PATH%\\launcher\\prepare.cjs" goto subminer_resources_missing', + 'set "ELECTRON_RUN_AS_NODE=1"', + "\"%SUBMINER_BINARY_PATH%\" -e \"const p=require('node:path');try{require(p.join(process.env.SUBMINER_RESOURCES_PATH,'launcher','prepare.cjs')).prepareLauncherRuntime({appPath:process.env.SUBMINER_BINARY_PATH,resourcesPath:process.env.SUBMINER_RESOURCES_PATH});}catch(error){console.error('Cannot prepare SubMiner launcher. Update or reinstall the SubMiner app.',error instanceof Error?error.message:String(error));process.exit(1);}\"", + 'set "SUBMINER_PREPARE_EXIT=%errorlevel%"', + 'set "ELECTRON_RUN_AS_NODE="', + 'if not "%SUBMINER_PREPARE_EXIT%"=="0" exit /b %SUBMINER_PREPARE_EXIT%', + 'if not exist "%SUBMINER_BUN_PATH%" goto subminer_prepare_missing', + ':subminer_run', + '"%SUBMINER_BUN_PATH%" "%SUBMINER_RESOURCES_PATH%\\launcher\\subminer.js" %*', + 'exit /b %errorlevel%', + ':subminer_app_missing', + '>&2 echo SubMiner app not found. Install the app or set SUBMINER_BINARY_PATH to its executable.', + 'exit /b 1', + ':subminer_local_app_data_missing', + '>&2 echo LOCALAPPDATA is unavailable. SubMiner cannot locate its private launcher runtime.', + 'exit /b 1', + ':subminer_resources_missing', + '>&2 echo This launcher requires a SubMiner app with the included Bun runtime. Update SubMiner.', + 'exit /b 1', + ':subminer_prepare_missing', + '>&2 echo SubMiner did not create its private launcher runtime. Update or reinstall SubMiner.', + 'exit /b 1', + '', + ].join('\r\n'); +} diff --git a/src/prerelease-workflow.test.ts b/src/prerelease-workflow.test.ts index ba1c819b..c18f636c 100644 --- a/src/prerelease-workflow.test.ts +++ b/src/prerelease-workflow.test.ts @@ -79,14 +79,14 @@ test('prerelease workflow builds and uploads all release platforms', () => { assert.match(prereleaseWorkflow, /name: windows/); }); -test('prerelease workflow publishes the same release assets as the stable workflow', () => { +test('prerelease workflow publishes both launcher wrappers with the platform packages', () => { assert.match( prereleaseWorkflow, - /files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz release\/latest\*\.yml release\/\*\.blockmap dist\/launcher\/subminer\)/, + /files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz release\/latest\*\.yml release\/\*\.blockmap dist\/launcher\/subminer dist\/launcher\/subminer\.cmd\)/, ); assert.match( prereleaseWorkflow, - /artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/latest\*\.yml[\s\S]*release\/\*\.blockmap[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/, + /artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/latest\*\.yml[\s\S]*release\/\*\.blockmap[\s\S]*release\/SHA256SUMS\.txt[\s\S]*dist\/launcher\/subminer[\s\S]*dist\/launcher\/subminer\.cmd[\s\S]*\)/, ); }); diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index e3cc3c7d..97a73cb0 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -16,6 +16,8 @@ const parsedReleaseWorkflow = readWorkflow(releaseWorkflowPath); const parsedDocsPagesWorkflow = readWorkflow(docsPagesWorkflowPath); const makefilePath = resolve(__dirname, '../Makefile'); const makefile = readFileSync(makefilePath, 'utf8'); +const buildLauncherPath = resolve(__dirname, '../scripts/build-launcher.ts'); +const buildLauncher = readFileSync(buildLauncherPath, 'utf8'); const packageJsonPath = resolve(__dirname, '../package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { desktopName?: string; @@ -32,6 +34,7 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { extraResources?: Array<{ from?: string; to?: string; + filter?: string[]; }>; mac?: { artifactName?: string; @@ -114,14 +117,14 @@ test('release workflow generates release notes from committed changelog output', assert.ok(!releaseWorkflow.includes('git log --pretty=format:"- %s"')); }); -test('release workflow includes the Windows installer in checksums and uploaded assets', () => { +test('release workflow includes the Windows installer and both launcher wrappers in release assets', () => { assert.match( releaseWorkflow, - /files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz release\/latest\*\.yml release\/\*\.blockmap dist\/launcher\/subminer\)/, + /files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz release\/latest\*\.yml release\/\*\.blockmap dist\/launcher\/subminer dist\/launcher\/subminer\.cmd\)/, ); assert.match( releaseWorkflow, - /artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/latest\*\.yml[\s\S]*release\/\*\.blockmap[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/, + /artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/latest\*\.yml[\s\S]*release\/\*\.blockmap[\s\S]*release\/SHA256SUMS\.txt[\s\S]*dist\/launcher\/subminer[\s\S]*dist\/launcher\/subminer\.cmd[\s\S]*\)/, ); }); @@ -196,15 +199,22 @@ test('release packaging keeps default file inclusion and excludes large source-o assert.ok(files.includes('!node_modules/@libsql/linux-x64-musl{,/**/*}')); }); -test('release packaging stages generated launcher as an app resource', () => { - assert.ok( - packageJson.build?.extraResources?.some( - (resource) => - resource.from === 'dist/launcher/subminer' && resource.to === 'launcher/subminer', - ), +test('release packaging stages only the generated launcher runtime artifacts', () => { + const launcherResource = packageJson.build?.extraResources?.find( + (resource) => resource.from === 'dist/launcher' && resource.to === 'launcher', ); + assert.deepEqual(launcherResource?.filter, [ + 'subminer', + 'subminer.cmd', + 'subminer.js', + 'prepare.cjs', + 'version', + ]); assert.match(packageJson.scripts.build ?? '', /bun run build:launcher/); - assert.match(packageJson.scripts['build:launcher'] ?? '', /--banner='#!\/usr\/bin\/env bun'/); + assert.equal(packageJson.scripts['build:launcher'], 'bun run scripts/build-launcher.ts'); + assert.match(buildLauncher, /banner: '#!\/usr\/bin\/env bun'/); + assert.match(buildLauncher, /posixLauncherBootstrapContent\(\)/); + assert.match(buildLauncher, /windowsLauncherBootstrapContent\(\)/); }); test('release packaging does not reference removed Windows window helper script', () => {