mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-11 05:16:27 -07:00
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:
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user