mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-10 17:16:20 -07:00
feat(launcher): bundle a private Bun runtime
This commit is contained in:
+14
-1
@@ -388,6 +388,7 @@ import {
|
||||
detectCommandLineLauncher,
|
||||
installBun as installCommandLineBun,
|
||||
installLauncher as installCommandLineLauncher,
|
||||
refreshManagedCommandLineLauncher,
|
||||
} from './main/runtime/command-line-launcher';
|
||||
import {
|
||||
createWindowsMpvLaunchDeps,
|
||||
@@ -1433,6 +1434,10 @@ const createCommandLineLauncherRuntimeOptions = () => ({
|
||||
cwd: process.cwd(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
appExePath: process.execPath,
|
||||
appVersion: app.getVersion(),
|
||||
bundledBunPath: app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bun', process.platform === 'win32' ? 'bun.exe' : 'bun')
|
||||
: undefined,
|
||||
});
|
||||
const firstRunSetupService = createFirstRunSetupService({
|
||||
platform: process.platform,
|
||||
@@ -1518,7 +1523,10 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
},
|
||||
installCommandLineLauncher: async () => {
|
||||
const snapshot = await installCommandLineLauncher(createCommandLineLauncherRuntimeOptions());
|
||||
const ok = snapshot.status === 'ready' || snapshot.status === 'installed_bun_missing';
|
||||
const ok =
|
||||
snapshot.status === 'ready' ||
|
||||
snapshot.status === 'installed_bun_missing' ||
|
||||
snapshot.status === 'not_on_path';
|
||||
return {
|
||||
ok,
|
||||
installPath: snapshot.installPath,
|
||||
@@ -6249,6 +6257,11 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
|
||||
|
||||
runAndApplyStartupState();
|
||||
void app.whenReady().then(() => {
|
||||
void refreshManagedCommandLineLauncher(createCommandLineLauncherRuntimeOptions()).catch(
|
||||
(error) => {
|
||||
logger.warn('Failed to refresh the installed command-line launcher', error);
|
||||
},
|
||||
);
|
||||
if (!shouldStartAutomaticUpdateChecks(appState.initialArgs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export type CommonOptions = FsDeps & {
|
||||
resourcesPath?: string;
|
||||
appExePath?: string;
|
||||
launcherResourcePath?: string;
|
||||
bundledBunPath?: string;
|
||||
appVersion?: string;
|
||||
runCommand?: RunCommand;
|
||||
};
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ test('resolveLauncherInstallTarget prefers writable user bin on Linux', async ()
|
||||
assert.equal(target.installPath, '/home/tester/.local/bin/subminer');
|
||||
});
|
||||
|
||||
test('resolveLauncherInstallTarget returns not_installable without writable PATH dirs', async () => {
|
||||
test('resolveLauncherInstallTarget offers a user bin without writable PATH dirs', async () => {
|
||||
const target = await resolveLauncherInstallTarget({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
@@ -200,8 +200,9 @@ test('resolveLauncherInstallTarget returns not_installable without writable PATH
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(target.status, 'not_installable');
|
||||
assert.equal(target.installPath, null);
|
||||
assert.equal(target.status, 'not_installed');
|
||||
assert.equal(target.installPath, '/home/tester/.local/bin/subminer');
|
||||
assert.match(target.message ?? '', /export PATH=/);
|
||||
});
|
||||
|
||||
test('resolveLauncherInstallTarget skips Homebrew bin for empty macOS manual installs', async () => {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
cleanupOldWindowsManagedRuntimes,
|
||||
isManagedLauncher,
|
||||
managedLauncherContent,
|
||||
managedLauncherPaths,
|
||||
shellQuote,
|
||||
stageManagedLauncher,
|
||||
windowsManagedRuntimePaths,
|
||||
} from './managed-launcher';
|
||||
import {
|
||||
accessSyncOf,
|
||||
envOf,
|
||||
@@ -115,8 +124,13 @@ export function resolveBunInstallCommand(
|
||||
}
|
||||
|
||||
export async function detectBun(options: CommonOptions = {}): Promise<BunSnapshot> {
|
||||
const bunPath = findCommand('bun', options);
|
||||
const installCommand = resolveBunInstallCommand(options);
|
||||
const bundled = options.bundledBunPath;
|
||||
const bunPath = bundled
|
||||
? existsSyncOf(options)(bundled)
|
||||
? bundled
|
||||
: null
|
||||
: findCommand('bun', options);
|
||||
const installCommand = bundled ? null : resolveBunInstallCommand(options);
|
||||
if (!bunPath) {
|
||||
return {
|
||||
status: 'missing',
|
||||
@@ -124,7 +138,9 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
|
||||
version: null,
|
||||
installMethod: installMethodForCommand(installCommand),
|
||||
installCommand,
|
||||
message: null,
|
||||
message: bundled
|
||||
? 'The included launcher runtime is missing. Reinstall SubMiner to repair it.'
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,7 +155,7 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
|
||||
version: result.stdout.trim() || null,
|
||||
installMethod: null,
|
||||
installCommand: null,
|
||||
message: null,
|
||||
message: bundled ? 'Included with SubMiner. No separate Bun installation is needed.' : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,6 +199,21 @@ function collectPathDirs(options: CommonOptions): string[] {
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function preferredLauncherDirs(platform: NodeJS.Platform, homeDir: string): string[] {
|
||||
return platform === 'darwin'
|
||||
? [
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
path.posix.join(homeDir, '.local', 'bin'),
|
||||
path.posix.join(homeDir, 'bin'),
|
||||
]
|
||||
: [
|
||||
path.posix.join(homeDir, '.local', 'bin'),
|
||||
path.posix.join(homeDir, 'bin'),
|
||||
'/usr/local/bin',
|
||||
];
|
||||
}
|
||||
|
||||
export async function resolveLauncherInstallTarget(
|
||||
options: CommonOptions & WindowsPathOptions = {},
|
||||
): Promise<LauncherSnapshot> {
|
||||
@@ -201,19 +232,7 @@ export async function resolveLauncherInstallTarget(
|
||||
|
||||
const homeDir = options.homeDir ?? os.homedir();
|
||||
const pathDirs = collectPathDirs(options);
|
||||
const preferred =
|
||||
platform === 'darwin'
|
||||
? [
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
path.posix.join(homeDir, '.local', 'bin'),
|
||||
path.posix.join(homeDir, 'bin'),
|
||||
]
|
||||
: [
|
||||
path.posix.join(homeDir, '.local', 'bin'),
|
||||
path.posix.join(homeDir, 'bin'),
|
||||
'/usr/local/bin',
|
||||
];
|
||||
const preferred = preferredLauncherDirs(platform, homeDir);
|
||||
const manualPreferred =
|
||||
platform === 'darwin'
|
||||
? [
|
||||
@@ -251,13 +270,15 @@ export async function resolveLauncherInstallTarget(
|
||||
isWritableDir(dir, options),
|
||||
);
|
||||
if (!selected) {
|
||||
const pathDir = path.posix.join(homeDir, '.local', 'bin');
|
||||
const installPath = path.posix.join(pathDir, 'subminer');
|
||||
return {
|
||||
status: 'not_installable',
|
||||
commandPath: null,
|
||||
installPath: null,
|
||||
pathDir: null,
|
||||
status: existsSyncOf(options)(installPath) ? 'not_on_path' : 'not_installed',
|
||||
commandPath: existsSyncOf(options)(installPath) ? installPath : null,
|
||||
installPath,
|
||||
pathDir,
|
||||
shadowedBy: null,
|
||||
message: 'No writable directory was found on your command-line PATH.',
|
||||
message: `Add ${pathDir} to your terminal PATH: export PATH=${shellQuote(pathDir)}:"$PATH". Save this in your shell configuration for future terminals.`,
|
||||
};
|
||||
}
|
||||
const installPath = path.posix.join(selected, 'subminer');
|
||||
@@ -283,7 +304,40 @@ export async function detectLauncher(
|
||||
const launcherResourcePath = resolveLauncherResourcePath(options);
|
||||
const appExePath = options.appExePath ?? process.execPath;
|
||||
|
||||
if (platform === 'win32' && existsSyncOf(options)(expectedPath)) {
|
||||
if (options.bundledBunPath && existsSyncOf(options)(expectedPath)) {
|
||||
const content = String((options.readFileSync ?? fs.readFileSync)(expectedPath, 'utf8'));
|
||||
if (!isManagedLauncher(content)) {
|
||||
return {
|
||||
...target,
|
||||
status: 'not_installed',
|
||||
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,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
...target,
|
||||
status: 'not_installed',
|
||||
message: 'Reinstall the launcher to refresh its SubMiner location.',
|
||||
};
|
||||
}
|
||||
} else if (platform === 'win32' && existsSyncOf(options)(expectedPath)) {
|
||||
const content = String((options.readFileSync ?? fs.readFileSync)(expectedPath, 'utf8'));
|
||||
if (!shimMatchesCurrentInstall(content, appExePath, launcherResourcePath)) {
|
||||
return {
|
||||
@@ -305,26 +359,19 @@ export async function detectLauncher(
|
||||
}
|
||||
if (!existsSyncOf(options)(expectedPath))
|
||||
return { ...target, status: 'not_installed', commandPath: null };
|
||||
if (!commandPath) {
|
||||
return {
|
||||
...target,
|
||||
status: 'not_on_path',
|
||||
commandPath: expectedPath,
|
||||
message: 'Launcher exists but its directory is not on PATH.',
|
||||
};
|
||||
}
|
||||
|
||||
const bunSnapshot = options.bunSnapshot ?? (await detectBun(options));
|
||||
if (bunSnapshot.status !== 'ready') {
|
||||
return {
|
||||
...target,
|
||||
status: 'installed_bun_missing',
|
||||
commandPath,
|
||||
message: 'Launcher is installed, but Bun is missing. Install Bun, then open a new terminal.',
|
||||
message: options.bundledBunPath
|
||||
? bunSnapshot.message
|
||||
: 'Launcher is installed, but Bun is missing. Install Bun, then open a new terminal.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await getRunCommand(options)(commandPath, ['--help'], {
|
||||
const result = await getRunCommand(options)(expectedPath, ['--help'], {
|
||||
timeoutMs: COMMAND_TIMEOUT_MS,
|
||||
env: envOf(options) as NodeJS.ProcessEnv,
|
||||
});
|
||||
@@ -336,6 +383,16 @@ export async function detectLauncher(
|
||||
message: failureMessage(result, 'subminer --help failed'),
|
||||
};
|
||||
}
|
||||
if (!commandPath) {
|
||||
return {
|
||||
...target,
|
||||
status: 'not_on_path',
|
||||
commandPath: expectedPath,
|
||||
message:
|
||||
target.message ??
|
||||
`Launcher installed. Add ${target.pathDir} to your terminal PATH: export PATH=${shellQuote(target.pathDir ?? '')}:"$PATH". Save this in your shell configuration for future terminals.`,
|
||||
};
|
||||
}
|
||||
return { ...target, status: 'ready', commandPath, message: null };
|
||||
}
|
||||
|
||||
@@ -354,6 +411,49 @@ export async function installLauncher(
|
||||
};
|
||||
}
|
||||
|
||||
if (options.bundledBunPath) {
|
||||
const bun = await detectBun(options);
|
||||
if (bun.status !== 'ready')
|
||||
return {
|
||||
...target,
|
||||
status: 'failed',
|
||||
message: bun.message ?? 'The included launcher runtime failed to start.',
|
||||
};
|
||||
try {
|
||||
const payload = stageManagedLauncher({
|
||||
...options,
|
||||
bundledBunPath: options.bundledBunPath,
|
||||
launcherResourcePath,
|
||||
force: true,
|
||||
});
|
||||
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
|
||||
(options.writeFileSync ?? fs.writeFileSync)(
|
||||
target.installPath,
|
||||
managedLauncherContent({
|
||||
platform,
|
||||
...payload,
|
||||
appPath: envOf(options).APPIMAGE ?? options.appExePath ?? process.execPath,
|
||||
}),
|
||||
);
|
||||
(options.chmodSync ?? fs.chmodSync)(target.installPath, 0o755);
|
||||
if (platform === 'win32') {
|
||||
cleanupOldWindowsManagedRuntimes(options);
|
||||
const nextPath = await appendWindowsUserPathDir(target.pathDir, options);
|
||||
if (nextPath && options.env) {
|
||||
options.env.PATH = nextPath;
|
||||
options.env.Path = nextPath;
|
||||
}
|
||||
}
|
||||
return await detectLauncher({ ...options, bunSnapshot: bun });
|
||||
} catch (error) {
|
||||
return {
|
||||
...target,
|
||||
status: 'failed',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
|
||||
(options.writeFileSync ?? fs.writeFileSync)(
|
||||
@@ -375,6 +475,8 @@ export async function installLauncher(
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (!existsSyncOf(options)(target.pathDir))
|
||||
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
|
||||
(options.copyFileSync ?? fs.copyFileSync)(launcherResourcePath, target.installPath);
|
||||
(options.chmodSync ?? fs.chmodSync)(target.installPath, 0o755);
|
||||
}
|
||||
@@ -384,6 +486,7 @@ export async function installLauncher(
|
||||
export async function installBun(
|
||||
options: CommonOptions & WindowsPathOptions = {},
|
||||
): Promise<BunSnapshot> {
|
||||
if (options.bundledBunPath) return detectBun(options);
|
||||
const platform = platformOf(options);
|
||||
if (platform === 'win32') {
|
||||
const bunDir = defaultBunRepairPath(options);
|
||||
@@ -455,6 +558,41 @@ export async function installBun(
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshManagedCommandLineLauncher(
|
||||
options: CommonOptions & WindowsPathOptions,
|
||||
): Promise<void> {
|
||||
if (!options.bundledBunPath) return;
|
||||
const target = await resolveLauncherInstallTarget(options);
|
||||
const platform = platformOf(options);
|
||||
const candidates = new Set([
|
||||
...(target.installPath ? [target.installPath] : []),
|
||||
...(platform === 'win32'
|
||||
? []
|
||||
: preferredLauncherDirs(platform, options.homeDir ?? os.homedir()).map((directory) =>
|
||||
path.posix.join(directory, 'subminer'),
|
||||
)),
|
||||
]);
|
||||
const readFile = options.readFileSync ?? fs.readFileSync;
|
||||
let payload: ReturnType<typeof stageManagedLauncher> | undefined;
|
||||
for (const candidate of candidates) {
|
||||
if (!existsSyncOf(options)(candidate)) continue;
|
||||
const existing = String(readFile(candidate, 'utf8'));
|
||||
if (!isManagedLauncher(existing)) continue;
|
||||
payload ??= stageManagedLauncher({
|
||||
...options,
|
||||
bundledBunPath: options.bundledBunPath,
|
||||
launcherResourcePath: resolveLauncherResourcePath(options),
|
||||
});
|
||||
const content = managedLauncherContent({
|
||||
platform,
|
||||
...payload,
|
||||
appPath: envOf(options).APPIMAGE ?? options.appExePath ?? process.execPath,
|
||||
});
|
||||
if (existing !== content) (options.writeFileSync ?? fs.writeFileSync)(candidate, content);
|
||||
}
|
||||
if (platform === 'win32' && payload) cleanupOldWindowsManagedRuntimes(options);
|
||||
}
|
||||
|
||||
export async function detectCommandLineLauncher(
|
||||
options: CommonOptions & WindowsPathOptions = {},
|
||||
): Promise<CommandLineLauncherSnapshot> {
|
||||
|
||||
@@ -311,8 +311,8 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions',
|
||||
});
|
||||
|
||||
assert.match(html, /Command line launcher/);
|
||||
assert.match(html, /Optional\. Setup can finish without Bun or the launcher\./);
|
||||
assert.match(html, /Bun runtime/);
|
||||
assert.match(html, /Optional\. Install the launcher to use SubMiner from your terminal\./);
|
||||
assert.match(html, /Launcher runtime/);
|
||||
assert.match(html, /Failed/);
|
||||
assert.match(html, /bash -lc curl -fsSL https:\/\/bun\.com\/install \| bash/);
|
||||
assert.match(html, /Install Bun/);
|
||||
|
||||
@@ -143,7 +143,7 @@ function renderCommandLineLauncherSection(
|
||||
].filter(Boolean)
|
||||
: [
|
||||
bun.installMethod ? `Method: ${bun.installMethod}` : null,
|
||||
`Command: ${formatCommand(bun.installCommand)}`,
|
||||
bun.installCommand ? `Command: ${formatCommand(bun.installCommand)}` : null,
|
||||
bun.message,
|
||||
].filter(Boolean);
|
||||
const launcherMeta = [
|
||||
@@ -152,24 +152,28 @@ function renderCommandLineLauncherSection(
|
||||
launcher.pathDir ? `PATH dir: ${launcher.pathDir}` : null,
|
||||
launcher.shadowedBy ? `Shadowed by: ${launcher.shadowedBy}` : null,
|
||||
launcher.message,
|
||||
bun.status !== 'ready' ? 'Warning: subminer will not run until Bun is available.' : null,
|
||||
bun.status !== 'ready'
|
||||
? 'The launcher runtime must be ready before installing the launcher.'
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
const bunInstallButton =
|
||||
bun.status === 'missing' || bun.status === 'failed'
|
||||
bun.installCommand && (bun.status === 'missing' || bun.status === 'failed')
|
||||
? `<button onclick="window.location.href='subminer://first-run-setup?action=install-bun'">Install Bun</button>`
|
||||
: '';
|
||||
const launcherButtonDisabled = launcher.status === 'not_installable' ? 'disabled' : '';
|
||||
const launcherButtonDisabled =
|
||||
launcher.status === 'not_installable' || bun.status !== 'ready' ? 'disabled' : '';
|
||||
|
||||
return `
|
||||
<section class="setup-section">
|
||||
<div class="section-head">
|
||||
<h2>Command line launcher</h2>
|
||||
<div class="meta">Optional. Setup can finish without Bun or the launcher.</div>
|
||||
<div class="meta">Optional. Install the launcher to use SubMiner from your terminal.</div>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<strong>Bun runtime</strong>
|
||||
<strong>Launcher runtime</strong>
|
||||
${bun.message && bun.status === 'ready' ? `<div class="meta">${escapeHtml(bun.message)}</div>` : ''}
|
||||
${bunMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
|
||||
</div>
|
||||
${renderStatusBadge(getBunStatusLabel(bun.status), getToolTone(bun.status))}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
detectBun,
|
||||
installBun,
|
||||
installLauncher,
|
||||
refreshManagedCommandLineLauncher,
|
||||
} from './command-line-launcher';
|
||||
import {
|
||||
cleanupOldWindowsManagedRuntimes,
|
||||
MANAGED_LAUNCHER_MARKER,
|
||||
managedLauncherContent,
|
||||
managedLauncherPaths,
|
||||
stageManagedLauncher,
|
||||
windowsManagedRuntimePaths,
|
||||
} from './managed-launcher';
|
||||
|
||||
function workspace(t: test.TestContext) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "subminer bundled bun's "));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
test('a missing packaged Bun never falls back to system Bun or runs an installer', async () => {
|
||||
let calls = 0;
|
||||
const options = {
|
||||
bundledBunPath: '/missing/private/bun',
|
||||
env: { PATH: path.dirname(process.execPath) },
|
||||
existsSync: (candidate: string) => candidate === process.execPath,
|
||||
runCommand: async () => {
|
||||
calls += 1;
|
||||
return { exitCode: 0, stdout: '1.3.5', stderr: '' };
|
||||
},
|
||||
};
|
||||
for (const snapshot of [await detectBun(options), await installBun(options)]) {
|
||||
assert.equal(snapshot.status, 'missing');
|
||||
assert.equal(snapshot.installCommand, null);
|
||||
assert.match(snapshot.message ?? '', /Reinstall SubMiner/);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('packaged POSIX launcher works without Bun on PATH and survives AppImage unmount', async (t) => {
|
||||
if (process.platform !== 'linux') return;
|
||||
const root = workspace(t);
|
||||
const resources = path.join(root, 'mounted resources');
|
||||
const bin = path.join(root, 'home', '.local', 'bin');
|
||||
fs.mkdirSync(resources, { recursive: true });
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
const launcherResourcePath = path.join(resources, 'subminer');
|
||||
const bundledBunPath = path.join(resources, 'bun');
|
||||
fs.symlinkSync(process.execPath, bundledBunPath);
|
||||
fs.writeFileSync(
|
||||
launcherResourcePath,
|
||||
'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');
|
||||
const options = {
|
||||
platform: process.platform,
|
||||
homeDir: path.join(root, 'home'),
|
||||
env: { PATH: bin, XDG_DATA_HOME: path.join(root, 'data'), APPIMAGE: appPath },
|
||||
appExePath: path.join(resources, 'SubMiner'),
|
||||
appVersion: '1.0.0',
|
||||
bundledBunPath,
|
||||
launcherResourcePath,
|
||||
};
|
||||
const installed = await installLauncher(options);
|
||||
assert.equal(installed.status, 'ready', installed.message ?? 'install failed');
|
||||
assert.match(fs.readFileSync(path.join(bin, 'subminer'), 'utf8'), /SubMiner managed launcher/);
|
||||
fs.renameSync(resources, path.join(root, 'unmounted'));
|
||||
const args = ['file with spaces.mkv', "single'quote", '$HOME', '$(touch nope)', '日本語'];
|
||||
const result = spawnSync(path.join(bin, 'subminer'), args, {
|
||||
env: options.env,
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), { args, app: appPath, managed: '1' });
|
||||
});
|
||||
|
||||
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');
|
||||
fs.writeFileSync(script, 'console.log("help");');
|
||||
const snapshot = await installLauncher({
|
||||
platform: 'darwin',
|
||||
homeDir: root,
|
||||
env: { PATH: '' },
|
||||
appExePath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
bundledBunPath: process.execPath,
|
||||
launcherResourcePath: script,
|
||||
});
|
||||
assert.equal(snapshot.status, 'not_on_path', snapshot.message ?? 'install failed');
|
||||
assert.equal(snapshot.installPath, path.join(root, '.local', 'bin', 'subminer'));
|
||||
assert.match(snapshot.message ?? '', /export PATH=/);
|
||||
const result = spawnSync(snapshot.installPath!, ['--help'], {
|
||||
env: { PATH: '' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
});
|
||||
|
||||
test('app upgrades refresh Linux managed payloads and leave standalone launchers alone', 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 options = {
|
||||
platform: process.platform,
|
||||
homeDir: root,
|
||||
env: { PATH: bin },
|
||||
appVersion: '1',
|
||||
appExePath: '/apps/SubMiner.AppImage',
|
||||
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' });
|
||||
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');
|
||||
await refreshManagedCommandLineLauncher({ ...options, appVersion: '3' });
|
||||
assert.equal(fs.readFileSync(payload.versionPath, 'utf8'), '2');
|
||||
});
|
||||
|
||||
test('Windows wrapper uses quoted absolute Bun and disables delayed expansion', () => {
|
||||
const content = managedLauncherContent({
|
||||
platform: 'win32',
|
||||
bunPath: 'C:\\Apps & Tools\\100%\\bun.exe',
|
||||
scriptPath: 'C:\\Apps & Tools\\subminer',
|
||||
appPath: 'C:\\Apps!\\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('exit /b %errorlevel%'));
|
||||
});
|
||||
|
||||
test('Windows managed runtime path is absolute, versioned, and injectable', () => {
|
||||
const paths = windowsManagedRuntimePaths({
|
||||
platform: 'win32',
|
||||
localAppData: 'D:\\Profiles\\テスト User\\AppData\\Local',
|
||||
appVersion: '1.2.3-beta.4',
|
||||
});
|
||||
assert.equal(
|
||||
paths.bunPath,
|
||||
'D:\\Profiles\\テスト User\\AppData\\Local\\SubMiner\\launcher-runtime\\1.2.3-beta.4\\bun.exe',
|
||||
);
|
||||
assert.ok(path.win32.isAbsolute(paths.bunPath));
|
||||
assert.throws(
|
||||
() =>
|
||||
windowsManagedRuntimePaths({
|
||||
platform: 'win32',
|
||||
localAppData: 'relative',
|
||||
appVersion: '1.2.3',
|
||||
}),
|
||||
/must be an absolute Windows path/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
windowsManagedRuntimePaths({
|
||||
platform: 'win32',
|
||||
localAppData: 'C:\\Users\\tester\\AppData\\Local',
|
||||
appVersion: '1:2',
|
||||
}),
|
||||
/not a valid directory name/,
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
fs.writeFileSync(script, 'console.log(JSON.stringify(process.argv.slice(2)));');
|
||||
const options = {
|
||||
platform: process.platform,
|
||||
env: { ...process.env, PATH: '' },
|
||||
bundledBunPath: process.execPath,
|
||||
launcherResourcePath: script,
|
||||
appExePath: path.join(root, 'SubMiner.exe'),
|
||||
appVersion: '1.0.0',
|
||||
localAppData: root,
|
||||
getUserPath: () => '',
|
||||
setUserPath: () => {},
|
||||
broadcastEnvironmentChange: () => {},
|
||||
};
|
||||
const snapshot = await installLauncher(options);
|
||||
assert.equal(snapshot.status, 'ready', snapshot.message ?? 'install failed');
|
||||
const { getRunCommand } = await import('./command-line-launcher-deps');
|
||||
const args = ['spaces here', 'a&b', 'bang!z', '日本語'];
|
||||
const result = await getRunCommand({})(snapshot.installPath!, args, { env: options.env });
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), args);
|
||||
});
|
||||
|
||||
test('Windows stages a new runtime version while the prior Bun executable is running', async (t) => {
|
||||
if (process.platform !== 'win32') return;
|
||||
const root = workspace(t);
|
||||
const bundledDirectory = path.join(root, 'packaged', 'bun');
|
||||
const bundledBunPath = path.join(bundledDirectory, 'bun.exe');
|
||||
const launcherResourcePath = path.join(root, 'packaged', 'launcher', 'subminer');
|
||||
fs.mkdirSync(path.join(bundledDirectory, 'licenses'), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(launcherResourcePath), { recursive: true });
|
||||
fs.copyFileSync(process.execPath, bundledBunPath);
|
||||
fs.writeFileSync(path.join(bundledDirectory, 'licenses', 'Bun-LICENSE.md'), 'license');
|
||||
fs.writeFileSync(launcherResourcePath, 'console.log("launcher");');
|
||||
|
||||
const first = stageManagedLauncher({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '1.0.0',
|
||||
bundledBunPath,
|
||||
launcherResourcePath,
|
||||
});
|
||||
const running = spawn(first.bunPath, ['-e', 'setInterval(() => {}, 1000)']);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
running.once('spawn', resolve);
|
||||
running.once('error', reject);
|
||||
});
|
||||
|
||||
const expectedSecond = windowsManagedRuntimePaths({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
});
|
||||
try {
|
||||
const second = stageManagedLauncher({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
bundledBunPath,
|
||||
launcherResourcePath,
|
||||
});
|
||||
assert.notEqual(second.bunPath, first.bunPath);
|
||||
assert.ok(fs.existsSync(second.bunPath));
|
||||
cleanupOldWindowsManagedRuntimes({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
});
|
||||
assert.ok(fs.existsSync(first.bunPath));
|
||||
assert.ok(fs.existsSync(path.join(path.dirname(first.bunPath), 'licenses', 'Bun-LICENSE.md')));
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(path.dirname(second.bunPath), 'licenses', 'Bun-LICENSE.md'),
|
||||
'utf8',
|
||||
),
|
||||
'license',
|
||||
);
|
||||
} finally {
|
||||
if (running.exitCode === null) {
|
||||
const exited = new Promise<void>((resolve) => running.once('exit', () => resolve()));
|
||||
running.kill();
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
cleanupOldWindowsManagedRuntimes({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
});
|
||||
assert.equal(fs.existsSync(path.dirname(first.bunPath)), false);
|
||||
assert.ok(fs.existsSync(expectedSecond.bunPath));
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
envOf,
|
||||
existsSyncOf,
|
||||
pathModuleFor,
|
||||
platformOf,
|
||||
type CommonOptions,
|
||||
type WindowsPathOptions,
|
||||
} from './command-line-launcher-deps';
|
||||
|
||||
export const MANAGED_LAUNCHER_MARKER = 'SubMiner managed launcher (bundled runtime)';
|
||||
|
||||
export function isManagedLauncher(content: string): boolean {
|
||||
const lines = content.split(/\r?\n/, 3);
|
||||
return (
|
||||
(lines[0] === '#!/bin/sh' && lines[1] === `# ${MANAGED_LAUNCHER_MARKER}`) ||
|
||||
(lines[0] === '@echo off' && lines[1] === `rem ${MANAGED_LAUNCHER_MARKER}`)
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
export function managedLauncherPaths(options: CommonOptions) {
|
||||
const platform = platformOf(options);
|
||||
const platformPath = pathModuleFor(platform);
|
||||
const env = envOf(options);
|
||||
const home = options.homeDir ?? os.homedir();
|
||||
const dataHome = env.XDG_DATA_HOME?.trim();
|
||||
const directory = platformPath.join(
|
||||
dataHome && platformPath.isAbsolute(dataHome)
|
||||
? dataHome
|
||||
: platformPath.join(home, '.local', 'share'),
|
||||
'SubMiner',
|
||||
'launcher',
|
||||
);
|
||||
return {
|
||||
directory,
|
||||
bunPath: platformPath.join(directory, 'bun'),
|
||||
scriptPath: platformPath.join(directory, 'subminer'),
|
||||
versionPath: platformPath.join(directory, 'version'),
|
||||
};
|
||||
}
|
||||
|
||||
function absoluteWindowsPath(candidate: string, label: string): string {
|
||||
const normalized = path.win32.normalize(candidate);
|
||||
if (!path.win32.isAbsolute(normalized)) {
|
||||
throw new Error(`${label} must be an absolute Windows path: ${candidate}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function windowsManagedRuntimePaths(options: CommonOptions & WindowsPathOptions) {
|
||||
const env = envOf(options);
|
||||
const userProfile = options.userProfile?.trim() || env.USERPROFILE?.trim() || os.homedir();
|
||||
const localAppData =
|
||||
options.localAppData?.trim() ||
|
||||
env.LOCALAPPDATA?.trim() ||
|
||||
path.win32.join(userProfile, 'AppData', 'Local');
|
||||
const rootDirectory = path.win32.join(
|
||||
absoluteWindowsPath(localAppData, 'Windows local app data directory'),
|
||||
'SubMiner',
|
||||
'launcher-runtime',
|
||||
);
|
||||
const version = options.appVersion?.trim() || 'development';
|
||||
if (
|
||||
version === '.' ||
|
||||
version === '..' ||
|
||||
/[<>:"/\\|?*\u0000-\u001f]/.test(version) ||
|
||||
/[ .]$/.test(version)
|
||||
) {
|
||||
throw new Error(`SubMiner version is not a valid directory name: ${version}`);
|
||||
}
|
||||
const directory = path.win32.join(rootDirectory, version);
|
||||
return {
|
||||
rootDirectory,
|
||||
directory,
|
||||
bunPath: path.win32.join(directory, 'bun.exe'),
|
||||
};
|
||||
}
|
||||
|
||||
export function cleanupOldWindowsManagedRuntimes(
|
||||
options: CommonOptions & WindowsPathOptions,
|
||||
): void {
|
||||
const paths = windowsManagedRuntimePaths(options);
|
||||
try {
|
||||
for (const entry of fs.readdirSync(paths.rootDirectory, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name === path.win32.basename(paths.directory)) continue;
|
||||
const oldDirectory = path.win32.join(paths.rootDirectory, entry.name);
|
||||
try {
|
||||
fs.rmSync(path.win32.join(oldDirectory, 'bun.exe'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(oldDirectory, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Cleanup is best-effort after proving the old runtime is not locked.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// The runtime root can be absent before the first managed launcher install.
|
||||
}
|
||||
}
|
||||
|
||||
function stageWindowsManagedRuntime(
|
||||
options: CommonOptions &
|
||||
WindowsPathOptions & {
|
||||
bundledBunPath: string;
|
||||
force?: boolean;
|
||||
},
|
||||
): string {
|
||||
const paths = windowsManagedRuntimePaths(options);
|
||||
const exists = existsSyncOf(options);
|
||||
const mkdir = options.mkdirSync ?? fs.mkdirSync;
|
||||
const copy = options.copyFileSync ?? fs.copyFileSync;
|
||||
|
||||
mkdir(paths.directory, { recursive: true });
|
||||
if (options.force || !exists(paths.bunPath)) {
|
||||
const stagingPath = `${paths.bunPath}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
copy(options.bundledBunPath, stagingPath);
|
||||
if (exists(paths.bunPath)) fs.rmSync(paths.bunPath, { force: true });
|
||||
fs.renameSync(stagingPath, paths.bunPath);
|
||||
} finally {
|
||||
fs.rmSync(stagingPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const packagedLicenses = path.join(path.dirname(options.bundledBunPath), 'licenses');
|
||||
const cachedLicenses = path.win32.join(paths.directory, 'licenses');
|
||||
if (exists(packagedLicenses) && (options.force || !exists(cachedLicenses))) {
|
||||
fs.cpSync(packagedLicenses, cachedLicenses, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return paths.bunPath;
|
||||
}
|
||||
|
||||
// Keep ephemeral or replaceable executables outside the installed app. Linux
|
||||
// also copies the script because AppImage resources disappear when the app exits.
|
||||
export function stageManagedLauncher(
|
||||
options: CommonOptions &
|
||||
WindowsPathOptions & {
|
||||
bundledBunPath: string;
|
||||
launcherResourcePath: string;
|
||||
force?: boolean;
|
||||
},
|
||||
) {
|
||||
const platform = platformOf(options);
|
||||
if (platform === 'win32') {
|
||||
return {
|
||||
bunPath: stageWindowsManagedRuntime(options),
|
||||
scriptPath: options.launcherResourcePath,
|
||||
};
|
||||
}
|
||||
if (platform !== 'linux') {
|
||||
return { bunPath: options.bundledBunPath, scriptPath: options.launcherResourcePath };
|
||||
}
|
||||
const paths = managedLauncherPaths(options);
|
||||
const exists = existsSyncOf(options);
|
||||
const read = options.readFileSync ?? fs.readFileSync;
|
||||
const write = options.writeFileSync ?? fs.writeFileSync;
|
||||
const copy = options.copyFileSync ?? fs.copyFileSync;
|
||||
const mkdir = options.mkdirSync ?? fs.mkdirSync;
|
||||
const chmod = options.chmodSync ?? fs.chmodSync;
|
||||
const version = options.appVersion ?? 'development';
|
||||
if (
|
||||
!options.force &&
|
||||
exists(paths.versionPath) &&
|
||||
read(paths.versionPath, 'utf8') === version &&
|
||||
exists(paths.bunPath) &&
|
||||
exists(paths.scriptPath)
|
||||
)
|
||||
return paths;
|
||||
|
||||
mkdir(paths.directory, { recursive: true });
|
||||
const staging = fs.mkdtempSync(path.join(paths.directory, '.stage-'));
|
||||
try {
|
||||
copy(options.bundledBunPath, path.join(staging, 'bun'));
|
||||
chmod(path.join(staging, 'bun'), 0o755);
|
||||
copy(options.launcherResourcePath, path.join(staging, 'subminer'));
|
||||
const notices = path.join(path.dirname(options.bundledBunPath), 'licenses');
|
||||
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']) {
|
||||
fs.renameSync(path.join(staging, name), path.join(paths.directory, name));
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
@@ -124,3 +124,38 @@ test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher conte
|
||||
assert.equal(suspicious.status, 'skipped');
|
||||
assert.equal(mismatch.status, 'hash-mismatch');
|
||||
});
|
||||
|
||||
test('app-managed wrappers are never overwritten by the standalone release script', async () => {
|
||||
const { managedLauncherContent } = await import('../managed-launcher');
|
||||
let downloaded = false;
|
||||
const result = await updateLauncherAtPath({
|
||||
launcherPath: '/home/tester/.local/bin/subminer',
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: launcherHash,
|
||||
download: async () => {
|
||||
downloaded = true;
|
||||
return launcherBytes;
|
||||
},
|
||||
fs: {
|
||||
stat: async () => ({ isFile: () => true }),
|
||||
readFile: async () =>
|
||||
managedLauncherContent({
|
||||
platform: 'linux',
|
||||
bunPath: '/private/bun',
|
||||
scriptPath: '/private/subminer',
|
||||
appPath: '/apps/SubMiner.AppImage',
|
||||
}),
|
||||
access: async () => {
|
||||
throw new Error('must not modify wrapper');
|
||||
},
|
||||
writeFile: async () => {
|
||||
throw new Error('must not modify wrapper');
|
||||
},
|
||||
chmod: async () => {},
|
||||
rename: async () => {},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 'skipped');
|
||||
assert.equal(downloaded, false);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { GitHubRelease } from './release-assets';
|
||||
import { findReleaseAsset } from './release-assets';
|
||||
import { isManagedLauncher } from '../managed-launcher';
|
||||
|
||||
type StatLike = {
|
||||
isFile: () => boolean;
|
||||
@@ -96,6 +97,13 @@ export async function updateLauncherAtPath(options: {
|
||||
}
|
||||
|
||||
const existing = await fsDeps.readFile(options.launcherPath);
|
||||
if (isManagedLauncher(existing.toString())) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
path: options.launcherPath,
|
||||
message: 'This launcher is updated with the SubMiner app.',
|
||||
};
|
||||
}
|
||||
if (!looksLikeSubminerLauncher(existing)) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
|
||||
Reference in New Issue
Block a user