feat(launcher): use private Bun for all release launchers

This commit is contained in:
2026-09-10 13:14:16 -07:00
parent 84b234cc19
commit 1213203228
38 changed files with 905 additions and 181 deletions
+6 -5
View File
@@ -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
+22 -3
View File
@@ -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
+6 -5
View File
@@ -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
+2 -8
View File
@@ -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"
+5 -3
View File
@@ -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.
</details>
<details>
@@ -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
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: launcher
- Documented private-runtime launcher installation, custom app locations, legacy launcher migration, and package-managed updates.
+2 -2
View File
@@ -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.
+13 -18
View File
@@ -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-<version>.exe` - installer (recommended)
- `SubMiner-<version>-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
+5 -3
View File
@@ -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/<version>` 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.
+1 -1
View File
@@ -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.
+6
View File
@@ -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<version>-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-<version>-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.
+1 -1
View File
@@ -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/<version>` 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
+1 -1
View File
@@ -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
+81
View File
@@ -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[] = [];
+44 -22
View File
@@ -58,6 +58,7 @@ type UpdateCommandDeps = {
) => { status: number; stdout: string; stderr: string; error?: Error };
waitForUpdateResponse: (responsePath: string) => Promise<UpdateCommandResponse>;
removeDir: (targetPath: string) => void;
resolveRealPath: (targetPath: string) => string;
runDirectReleaseUpdate: (
request: DirectReleaseUpdateRequest,
) => Promise<DirectReleaseUpdateResult>;
@@ -98,30 +99,36 @@ async function runDirectReleaseUpdate(
: new Map<string, string>();
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) {
+10 -3
View File
@@ -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",
-1
View File
@@ -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
-1
View File
@@ -9,7 +9,6 @@ url='https://github.com/ksyasuda/SubMiner'
license=('GPL-3.0-or-later')
options=('!strip' '!debug')
depends=(
'bun'
'fuse2'
'glibc'
'mpv'
+2
View File
@@ -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 });
}
+2 -2
View File
@@ -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');
}
+54
View File
@@ -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}`);
+2
View File
@@ -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,
+26 -11
View File
@@ -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[*]}"
+22 -19
View File
@@ -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);
+52 -17
View File
@@ -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: () => '',
+23 -36
View File
@@ -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 {
@@ -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.
});
@@ -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" "$@"
`;
}
@@ -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;
}
@@ -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', () => {
@@ -25,6 +25,7 @@ export interface AppImageUpdateResult {
}
export interface AppImageUpdateFileSystem {
realpath?: (targetPath: string) => Promise<string>;
stat: (targetPath: string) => Promise<StatLike>;
access: (targetPath: string) => Promise<void>;
writeFile: (targetPath: string, data: Buffer) => Promise<void>;
@@ -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);
@@ -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);
});
@@ -83,6 +83,7 @@ export async function updateLauncherAtPath(options: {
assetUrl: string;
expectedSha256: string;
download: () => Promise<Buffer>;
deferRecognizedLauncherUpdate?: boolean;
fs?: LauncherUpdateFileSystem;
}): Promise<LauncherUpdateResult> {
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<Buffer>;
deferRecognizedLauncherUpdate?: boolean;
exists?: (targetPath: string) => boolean;
fs?: LauncherUpdateFileSystem;
}): Promise<LauncherUpdateResult> {
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,
});
}
@@ -79,6 +79,7 @@ export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
sha256Sums: sums,
launcherPath,
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
deferRecognizedLauncherUpdate: true,
});
return runSupportAssetUpdatesForLauncherResult({
launcherResult,
@@ -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',
'<left>',
'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);
});
@@ -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');
}
+3 -3
View File
@@ -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]*\)/,
);
});
+20 -10
View File
@@ -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', () => {