feat(launcher): bundle Bun runtime with launcher artifacts

- Use private Bun runtimes for installed and downloadable launchers
- Package runtime licenses, source manifests, and corresponding source releases
- Update launcher workflows, setup, updates, and documentation
This commit is contained in:
2026-09-11 00:53:52 -07:00
81 changed files with 7637 additions and 343 deletions
+110
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[] = [];
@@ -148,3 +229,32 @@ test('runUpdateCommand keeps app-mediated update path on non-Linux', async () =>
'remove:/tmp/subminer-update-test',
]);
});
test('managed launcher passes its wrapper to app updates, protecting signed resources', async () => {
const previous = process.env.SUBMINER_LAUNCHER_PATH;
process.env.SUBMINER_LAUNCHER_PATH = '/Users/tester/.local/bin/subminer';
try {
let forwarded: string[] = [];
await runUpdateCommand(
makeContext({
processAdapter: { ...makeContext().processAdapter, platform: () => 'darwin' },
scriptPath: '/Applications/SubMiner.app/Contents/Resources/launcher/subminer',
appPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
}),
{
createTempDir: () => '/tmp/subminer-update-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandCaptureOutput: (_app, args) => {
forwarded = args;
return { status: 0, stdout: '', stderr: '' };
},
waitForUpdateResponse: async () => ({ ok: true }),
removeDir: () => {},
},
);
assert.equal(forwarded[2], '/Users/tester/.local/bin/subminer');
} finally {
if (previous === undefined) delete process.env.SUBMINER_LAUNCHER_PATH;
else process.env.SUBMINER_LAUNCHER_PATH = previous;
}
});
+45 -17
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,25 +99,36 @@ async function runDirectReleaseUpdate(
: new Map<string, string>();
const downloadAsset = (url: string) => fetchReleaseAssetBuffer(fetchForUpdater, url);
const [appImage, launcher, supportAssets] = await Promise.all([
updateAppImageFromRelease({
release,
sha256Sums,
appImagePath: request.appPath,
downloadAsset,
}),
updateLauncherFromRelease({
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,
launcherPath: request.launcherPath,
downloadAsset,
}),
updateSupportAssetsFromRelease({
release,
sha256Sums,
downloadAsset,
}),
]);
});
}
const supportAssets = await updateSupportAssetsFromRelease({
release,
sha256Sums,
downloadAsset,
});
return { appImage, launcher, supportAssets };
}
@@ -173,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,
@@ -189,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) {
@@ -203,6 +230,7 @@ export async function runUpdateCommand(
return true;
}
const launcherPath = path.resolve(process.env.SUBMINER_LAUNCHER_PATH ?? scriptPath);
const tempDir = resolvedDeps.createTempDir('subminer-update-');
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
@@ -210,7 +238,7 @@ export async function runUpdateCommand(
const result = resolvedDeps.runAppCommandCaptureOutput(appPath, [
'--update',
'--update-launcher-path',
scriptPath,
launcherPath,
'--update-response-path',
responsePath,
]);