mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-12 05:16:19 -07:00
feat(launcher): bundle Bun and use it across all launchers (#243)
This commit is contained in:
+16
-2
@@ -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,7 @@ 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 === 'not_on_path';
|
||||
return {
|
||||
ok,
|
||||
installPath: snapshot.installPath,
|
||||
@@ -5291,7 +5296,7 @@ const { getChangelogSnapshot } = createChangelogRuntime({
|
||||
logWarn: (message) => logger.warn(message),
|
||||
});
|
||||
|
||||
const { getUpdateService } = createUpdateServiceRuntime({
|
||||
const { getUpdateService, takePendingLauncherMigrationPath } = createUpdateServiceRuntime({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
getUpdatesConfig: () => configService.getConfig().updates,
|
||||
logInfo: (message) => logger.info(message),
|
||||
@@ -6249,6 +6254,15 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
|
||||
|
||||
runAndApplyStartupState();
|
||||
void app.whenReady().then(() => {
|
||||
void takePendingLauncherMigrationPath(async (pendingLauncherPath) => {
|
||||
const acknowledgedPaths = await refreshManagedCommandLineLauncher({
|
||||
...createCommandLineLauncherRuntimeOptions(),
|
||||
additionalLauncherPaths: pendingLauncherPath ? [pendingLauncherPath] : [],
|
||||
});
|
||||
return pendingLauncherPath !== undefined && acknowledgedPaths.includes(pendingLauncherPath);
|
||||
}).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;
|
||||
};
|
||||
|
||||
@@ -143,8 +145,40 @@ function needsWindowsShell(command: string): boolean {
|
||||
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
|
||||
}
|
||||
|
||||
function quoteForWindowsShell(value: string): string {
|
||||
return `"${value.replace(/([&|<>^%!])/g, '^$1').replace(/"/g, '""')}"`;
|
||||
/*!
|
||||
* Windows command escaping adapted from cross-spawn 7.0.6.
|
||||
*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2018 Made With MOXY Lda <hello@moxy.studio>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g;
|
||||
|
||||
// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes
|
||||
// are consumed by cmd, leaving the quoted argument unchanged for the command.
|
||||
function escapeWindowsShellArgument(value: string): string {
|
||||
const quotesEscaped = value
|
||||
.replace(/(?=(\\+?)?)\1"/g, '$1$1\\"')
|
||||
.replace(/(?=(\\+?)?)\1$/, '$1$1');
|
||||
return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1');
|
||||
}
|
||||
|
||||
function createDefaultRunCommand(): RunCommand {
|
||||
@@ -153,16 +187,24 @@ function createDefaultRunCommand(): RunCommand {
|
||||
const useShell = needsWindowsShell(command);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = useShell
|
||||
? spawn(quoteForWindowsShell(command), args.map(quoteForWindowsShell), {
|
||||
env: options.env ?? process.env,
|
||||
windowsHide: false,
|
||||
shell: true,
|
||||
})
|
||||
: spawn(command, args, {
|
||||
env: options.env ?? process.env,
|
||||
windowsHide: false,
|
||||
});
|
||||
const env = options.env ?? process.env;
|
||||
if (useShell) {
|
||||
const shellCommand = [
|
||||
escapeWindowsShellArgument(command),
|
||||
...args.map(escapeWindowsShellArgument),
|
||||
].join(' ');
|
||||
const commandProcessor = env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe';
|
||||
child = spawn(commandProcessor, ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`], {
|
||||
env,
|
||||
windowsHide: false,
|
||||
windowsVerbatimArguments: true,
|
||||
});
|
||||
} else {
|
||||
child = spawn(command, args, {
|
||||
env,
|
||||
windowsHide: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
resolve({
|
||||
exitCode: 1,
|
||||
|
||||
@@ -91,43 +91,54 @@ test('resolveBunInstallCommand prefers winget on Windows', () => {
|
||||
test('default runCommand preserves Windows cmd metacharacter args', async (t) => {
|
||||
if (process.platform !== 'win32') return;
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-cmd-args-'));
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer cmd & 100% ! '));
|
||||
const scriptPath = path.join(tempDir, 'argv.cmd');
|
||||
const outputPath = path.join(tempDir, 'argv.txt');
|
||||
const argvScriptPath = path.join(tempDir, 'argv.js');
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
fs.writeFileSync(
|
||||
argvScriptPath,
|
||||
'process.stdout.write(JSON.stringify(process.argv.slice(2)));',
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
'@echo off',
|
||||
'setlocal DisableDelayedExpansion',
|
||||
'> "%SUBMINER_ARGV_OUT%" (',
|
||||
' echo 1=%~1',
|
||||
' echo 2=%~2',
|
||||
' echo 3=%~3',
|
||||
' echo 4=%~4',
|
||||
' echo 5=%~5',
|
||||
' echo 6=%~6',
|
||||
')',
|
||||
'"%SUBMINER_TEST_RUNTIME%" "%SUBMINER_ARGV_SCRIPT%" %*',
|
||||
'exit /b %errorlevel%',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await getRunCommand({})(
|
||||
scriptPath,
|
||||
['plain', 'has space', 'a&b', 'x|y', 'p%PATH%q', 'bang!z'],
|
||||
{
|
||||
env: { ...process.env, SUBMINER_ARGV_OUT: outputPath },
|
||||
const args = [
|
||||
'plain',
|
||||
'has space',
|
||||
'a&b',
|
||||
'x|y',
|
||||
'p%TEMP%q',
|
||||
'bang!z',
|
||||
'caret^z',
|
||||
'<left>',
|
||||
'say "hi"',
|
||||
'slash\\"quote',
|
||||
'trailing\\',
|
||||
'',
|
||||
'日本語',
|
||||
];
|
||||
const result = await getRunCommand({})(scriptPath, args, {
|
||||
env: {
|
||||
...process.env,
|
||||
SUBMINER_ARGV_SCRIPT: argvScriptPath,
|
||||
SUBMINER_TEST_RUNTIME: process.execPath,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.equal(
|
||||
fs.readFileSync(outputPath, 'utf8'),
|
||||
['1=plain', '2=has space', '3=a&b', '4=x|y', '5=p%PATH%q', '6=bang!z', ''].join('\r\n'),
|
||||
);
|
||||
assert.deepEqual(JSON.parse(result.stdout), args);
|
||||
});
|
||||
|
||||
test('resolveBunInstallCommand falls back to scoop on Windows before official installer', () => {
|
||||
@@ -189,7 +200,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 +211,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,13 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
cleanupOldWindowsManagedRuntimes,
|
||||
isManagedLauncher,
|
||||
managedLauncherContent,
|
||||
shellQuote,
|
||||
stageManagedLauncher,
|
||||
} from './managed-launcher';
|
||||
import {
|
||||
accessSyncOf,
|
||||
envOf,
|
||||
@@ -115,8 +122,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 +136,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 +153,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,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 {
|
||||
@@ -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,29 @@ 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.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
content !==
|
||||
managedLauncherContent({
|
||||
platform,
|
||||
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 +348,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 +372,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 +400,48 @@ 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 {
|
||||
stageManagedLauncher({
|
||||
...options,
|
||||
bundledBunPath: options.bundledBunPath,
|
||||
launcherResourcePath,
|
||||
force: true,
|
||||
});
|
||||
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
|
||||
(options.writeFileSync ?? fs.writeFileSync)(
|
||||
target.installPath,
|
||||
managedLauncherContent({
|
||||
platform,
|
||||
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 +463,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 +474,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 +546,84 @@ export async function installBun(
|
||||
};
|
||||
}
|
||||
|
||||
// Runs at app startup. Migrates recognized launchers in the standard bin dirs,
|
||||
// the setup install target, and any paths a deferred update handed over.
|
||||
// Returns paths that were refreshed or are no longer eligible for migration.
|
||||
export async function refreshManagedCommandLineLauncher(
|
||||
options: CommonOptions & WindowsPathOptions & { additionalLauncherPaths?: string[] },
|
||||
): Promise<string[]> {
|
||||
if (!options.bundledBunPath) return [];
|
||||
const target = await resolveLauncherInstallTarget(options);
|
||||
const platform = platformOf(options);
|
||||
const platformPath = pathModuleFor(platform);
|
||||
// cmd.exe reads a batch file incrementally while it runs, so the launcher that
|
||||
// started this app is left alone until a later app start rewrites it.
|
||||
const runningLauncherPath =
|
||||
platform === 'win32' ? envOf(options).SUBMINER_LAUNCHER_PATH : undefined;
|
||||
const isRunningLauncher = (candidate: string) =>
|
||||
runningLauncherPath !== undefined &&
|
||||
platformPath.normalize(candidate).toLowerCase() ===
|
||||
platformPath.normalize(runningLauncherPath).toLowerCase();
|
||||
const candidates = new Set([
|
||||
...(target.installPath ? [target.installPath] : []),
|
||||
...(options.additionalLauncherPaths ?? []),
|
||||
...(platform === 'win32'
|
||||
? []
|
||||
: preferredLauncherDirs(platform, options.homeDir ?? os.homedir()).map((directory) =>
|
||||
path.posix.join(directory, 'subminer'),
|
||||
)),
|
||||
]);
|
||||
const readFile = options.readFileSync ?? fs.readFileSync;
|
||||
const acknowledgedPaths: string[] = [];
|
||||
let payload: ReturnType<typeof stageManagedLauncher> | undefined;
|
||||
for (const candidate of candidates) {
|
||||
if (isRunningLauncher(candidate)) continue;
|
||||
if (!existsSyncOf(options)(candidate)) {
|
||||
acknowledgedPaths.push(candidate);
|
||||
continue;
|
||||
}
|
||||
let existing: string;
|
||||
try {
|
||||
existing = String(readFile(candidate, 'utf8'));
|
||||
} catch {
|
||||
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) {
|
||||
acknowledgedPaths.push(candidate);
|
||||
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,
|
||||
launcherResourcePath: resolveLauncherResourcePath(options),
|
||||
});
|
||||
const content = managedLauncherContent({
|
||||
platform,
|
||||
appPath: envOf(options).APPIMAGE ?? options.appExePath ?? process.execPath,
|
||||
});
|
||||
if (existing !== content) (options.writeFileSync ?? fs.writeFileSync)(candidate, content);
|
||||
acknowledgedPaths.push(candidate);
|
||||
}
|
||||
if (platform === 'win32' && payload) cleanupOldWindowsManagedRuntimes(options);
|
||||
return acknowledgedPaths;
|
||||
}
|
||||
|
||||
export async function detectCommandLineLauncher(
|
||||
options: CommonOptions & WindowsPathOptions = {},
|
||||
): Promise<CommandLineLauncherSnapshot> {
|
||||
|
||||
@@ -271,7 +271,7 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
||||
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml renders command-line launcher section and actions', () => {
|
||||
test('buildFirstRunSetupHtml reports a broken included runtime in the optional launcher controls', () => {
|
||||
const html = buildFirstRunSetupHtml({
|
||||
configReady: true,
|
||||
dictionaryCount: 1,
|
||||
@@ -294,9 +294,9 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions',
|
||||
status: 'failed',
|
||||
commandPath: null,
|
||||
version: null,
|
||||
installMethod: 'official-script',
|
||||
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
|
||||
message: 'network failed',
|
||||
installMethod: null,
|
||||
installCommand: null,
|
||||
message: 'Included Bun runtime is missing.',
|
||||
},
|
||||
launcher: {
|
||||
status: 'installed_bun_missing',
|
||||
@@ -311,14 +311,11 @@ 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, /Failed/);
|
||||
assert.match(html, /bash -lc curl -fsSL https:\/\/bun\.com\/install \| bash/);
|
||||
assert.match(html, /Install Bun/);
|
||||
assert.match(html, /action=install-bun/);
|
||||
assert.match(html, /SubMiner launcher/);
|
||||
assert.match(html, /Installed, Bun missing/);
|
||||
assert.match(html, /Reinstall SubMiner to repair it/);
|
||||
assert.match(html, /<button disabled[^>]+action=install-command-line-launcher/);
|
||||
assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/);
|
||||
assert.match(html, /action=install-command-line-launcher/);
|
||||
assert.match(
|
||||
@@ -360,7 +357,7 @@ test('buildFirstRunSetupHtml disables launcher install when no target is install
|
||||
|
||||
assert.match(
|
||||
html,
|
||||
/<button disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=install-command-line-launcher'">Install launcher<\/button>/,
|
||||
/<button disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=install-command-line-launcher'">Install command-line launcher<\/button>/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
|
||||
import type {
|
||||
BunSnapshot,
|
||||
CommandLineLauncherSnapshot,
|
||||
LauncherSnapshot,
|
||||
} from './command-line-launcher';
|
||||
import type { CommandLineLauncherSnapshot, LauncherSnapshot } from './command-line-launcher';
|
||||
|
||||
type FocusableWindowLike = {
|
||||
focus: () => void;
|
||||
@@ -74,29 +70,12 @@ function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'da
|
||||
return `<span class="badge ${tone}">${escapeHtml(value)}</span>`;
|
||||
}
|
||||
|
||||
function formatCommand(command: string[] | null): string {
|
||||
return command?.join(' ') ?? 'No install command detected';
|
||||
}
|
||||
|
||||
function getBunStatusLabel(status: BunSnapshot['status']): string {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'Ready';
|
||||
case 'installing':
|
||||
return 'Installing';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
case 'missing':
|
||||
return 'Missing';
|
||||
}
|
||||
}
|
||||
|
||||
function getLauncherStatusLabel(status: LauncherSnapshot['status']): string {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'Ready';
|
||||
case 'installed_bun_missing':
|
||||
return 'Installed, Bun missing';
|
||||
return 'Unavailable';
|
||||
case 'not_installed':
|
||||
return 'Not installed';
|
||||
case 'not_on_path':
|
||||
@@ -110,13 +89,6 @@ function getLauncherStatusLabel(status: LauncherSnapshot['status']): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getToolTone(status: BunSnapshot['status']): 'ready' | 'warn' | 'muted' | 'danger' {
|
||||
if (status === 'ready') return 'ready';
|
||||
if (status === 'failed') return 'danger';
|
||||
if (status === 'installing') return 'muted';
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
function getLauncherTone(
|
||||
status: LauncherSnapshot['status'],
|
||||
): 'ready' | 'warn' | 'muted' | 'danger' {
|
||||
@@ -135,49 +107,26 @@ function renderCommandLineLauncherSection(
|
||||
|
||||
const bun = commandLineLauncher.bun;
|
||||
const launcher = commandLineLauncher.launcher;
|
||||
const bunMeta =
|
||||
bun.status === 'ready'
|
||||
? [
|
||||
bun.commandPath ? `Path: ${bun.commandPath}` : null,
|
||||
bun.version ? `Version: ${bun.version}` : null,
|
||||
].filter(Boolean)
|
||||
: [
|
||||
bun.installMethod ? `Method: ${bun.installMethod}` : null,
|
||||
`Command: ${formatCommand(bun.installCommand)}`,
|
||||
bun.message,
|
||||
].filter(Boolean);
|
||||
const runtimeUnavailable = bun.status !== 'ready';
|
||||
const launcherStatus = runtimeUnavailable ? 'failed' : launcher.status;
|
||||
const runtimeError = bun.installCommand
|
||||
? "The launcher runtime is unavailable. Install the project's Bun dependency, then refresh."
|
||||
: 'The launcher runtime is unavailable. Reinstall SubMiner to repair it.';
|
||||
const launcherMeta = [
|
||||
launcher.commandPath ? `Command: ${launcher.commandPath}` : null,
|
||||
launcher.installPath ? `Install target: ${launcher.installPath}` : null,
|
||||
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,
|
||||
runtimeUnavailable ? runtimeError : launcher.message,
|
||||
].filter(Boolean);
|
||||
const bunInstallButton =
|
||||
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>
|
||||
<div class="card block">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<strong>Bun runtime</strong>
|
||||
${bunMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
|
||||
</div>
|
||||
${renderStatusBadge(getBunStatusLabel(bun.status), getToolTone(bun.status))}
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
${bunInstallButton}
|
||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
|
||||
</div>
|
||||
<div class="meta">Optional. Install the launcher to use SubMiner from your terminal.</div>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<div class="card-head">
|
||||
@@ -185,10 +134,10 @@ function renderCommandLineLauncherSection(
|
||||
<strong>SubMiner launcher</strong>
|
||||
${launcherMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
|
||||
</div>
|
||||
${renderStatusBadge(getLauncherStatusLabel(launcher.status), getLauncherTone(launcher.status))}
|
||||
${renderStatusBadge(getLauncherStatusLabel(launcherStatus), getLauncherTone(launcherStatus))}
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<button ${launcherButtonDisabled} onclick="window.location.href='subminer://first-run-setup?action=install-command-line-launcher'">Install launcher</button>
|
||||
<button ${launcherButtonDisabled} onclick="window.location.href='subminer://first-run-setup?action=install-command-line-launcher'">Install command-line launcher</button>
|
||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
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 {
|
||||
createUpdateStateStore,
|
||||
takePendingLauncherMigrationPath,
|
||||
type UpdateState,
|
||||
} from './update/update-service';
|
||||
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');
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 73\n', { mode: 0o755 });
|
||||
const options = {
|
||||
platform: process.platform,
|
||||
homeDir: path.join(root, 'home'),
|
||||
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,
|
||||
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 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: appPath,
|
||||
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 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: { HOME: root, PATH: bin },
|
||||
appVersion: '1',
|
||||
appExePath: appPath,
|
||||
bundledBunPath: process.execPath,
|
||||
launcherResourcePath: script,
|
||||
};
|
||||
assert.equal((await installLauncher(options)).status, 'ready');
|
||||
fs.writeFileSync(script, 'console.log("new");');
|
||||
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'), '#!/usr/bin/env bun\n// SubMiner launcher\n');
|
||||
await refreshManagedCommandLineLauncher({ ...options, appVersion: '3' });
|
||||
assert.match(fs.readFileSync(path.join(bin, 'subminer'), 'utf8'), /SubMiner managed launcher/);
|
||||
const deferred = path.join(root, 'custom', 'subminer');
|
||||
fs.mkdirSync(path.dirname(deferred));
|
||||
fs.writeFileSync(deferred, '#!/usr/bin/env bun\n// SubMiner launcher\n');
|
||||
const unreadable = path.join(root, 'unreadable');
|
||||
fs.mkdirSync(unreadable);
|
||||
const acknowledged = await refreshManagedCommandLineLauncher({
|
||||
...options,
|
||||
appVersion: '3',
|
||||
additionalLauncherPaths: [unreadable, deferred],
|
||||
});
|
||||
assert.ok(!acknowledged.includes(unreadable));
|
||||
assert.ok(acknowledged.includes(deferred));
|
||||
assert.match(fs.readFileSync(deferred, 'utf8'), /SubMiner managed launcher/);
|
||||
fs.writeFileSync(path.join(bin, 'subminer'), '#!/bin/sh\necho standalone\n');
|
||||
const customAcknowledged = await refreshManagedCommandLineLauncher({
|
||||
...options,
|
||||
appVersion: '4',
|
||||
});
|
||||
assert.ok(customAcknowledged.includes(path.join(bin, 'subminer')));
|
||||
assert.equal(fs.readFileSync(payload.versionPath, 'utf8'), '3');
|
||||
});
|
||||
|
||||
test('Windows startup never rewrites the batch launcher that started the app', async () => {
|
||||
const programs = 'C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner';
|
||||
const installPath = 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin\\subminer.cmd';
|
||||
const writes: string[] = [];
|
||||
let state: UpdateState = { pendingLauncherMigrationPath: installPath };
|
||||
const store = createUpdateStateStore({
|
||||
readState: async () => state,
|
||||
writeState: async (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
});
|
||||
const options = {
|
||||
platform: 'win32' as const,
|
||||
localAppData: 'C:\\Users\\tester\\AppData\\Local',
|
||||
appVersion: '1.2.3',
|
||||
appExePath: `${programs}\\SubMiner.exe`,
|
||||
bundledBunPath: `${programs}\\resources\\bun\\bun.exe`,
|
||||
launcherResourcePath: `${programs}\\resources\\launcher\\subminer.js`,
|
||||
existsSync: (candidate: string) => !candidate.includes('licenses'),
|
||||
accessSync: () => {},
|
||||
mkdirSync: () => undefined,
|
||||
copyFileSync: () => {},
|
||||
readFileSync: () =>
|
||||
managedLauncherContent({ platform: 'win32', appPath: 'D:\\Old\\SubMiner.exe' }),
|
||||
writeFileSync: (candidate: string) => {
|
||||
writes.push(candidate);
|
||||
},
|
||||
};
|
||||
|
||||
await takePendingLauncherMigrationPath(store, async (pendingPath) => {
|
||||
const acknowledged = await refreshManagedCommandLineLauncher({
|
||||
...options,
|
||||
additionalLauncherPaths: pendingPath ? [pendingPath] : [],
|
||||
env: { SUBMINER_LAUNCHER_PATH: installPath.toUpperCase() },
|
||||
});
|
||||
return pendingPath !== undefined && acknowledged.includes(pendingPath);
|
||||
});
|
||||
assert.deepEqual(writes, []);
|
||||
assert.equal(state.pendingLauncherMigrationPath, installPath);
|
||||
|
||||
await takePendingLauncherMigrationPath(store, async (pendingPath) => {
|
||||
const acknowledged = await refreshManagedCommandLineLauncher({
|
||||
...options,
|
||||
additionalLauncherPaths: pendingPath ? [pendingPath] : [],
|
||||
env: {},
|
||||
});
|
||||
assert.equal(state.pendingLauncherMigrationPath, installPath);
|
||||
return pendingPath !== undefined && acknowledged.includes(pendingPath);
|
||||
});
|
||||
assert.deepEqual(writes, [installPath]);
|
||||
assert.equal(state.pendingLauncherMigrationPath, undefined);
|
||||
});
|
||||
|
||||
test('Windows wrapper discovers the configured app and its versioned private runtime', () => {
|
||||
const content = managedLauncherContent({
|
||||
platform: 'win32',
|
||||
appPath: 'C:\\Apps 100% !\\SubMiner.exe',
|
||||
});
|
||||
assert.ok(content.includes(MANAGED_LAUNCHER_MARKER));
|
||||
assert.ok(content.includes('setlocal DisableDelayedExpansion'));
|
||||
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%'));
|
||||
});
|
||||
|
||||
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 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: '', LOCALAPPDATA: root },
|
||||
bundledBunPath: process.execPath,
|
||||
launcherResourcePath: script,
|
||||
appExePath: appPath,
|
||||
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', 'p%TEMP%q', 'bang!z', 'say "hi"', '日本語'];
|
||||
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));
|
||||
});
|
||||
|
||||
test('Windows cleanup removes an obsolete runtime with no Bun executable', (t) => {
|
||||
if (process.platform !== 'win32') return;
|
||||
const root = workspace(t);
|
||||
const current = windowsManagedRuntimePaths({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
});
|
||||
const obsolete = windowsManagedRuntimePaths({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '1.0.0',
|
||||
});
|
||||
fs.mkdirSync(path.join(path.dirname(obsolete.bunPath), 'licenses'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(path.dirname(obsolete.bunPath), 'licenses', 'Bun-LICENSE.md'),
|
||||
'license',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(current.bunPath), { recursive: true });
|
||||
|
||||
cleanupOldWindowsManagedRuntimes({
|
||||
platform: 'win32',
|
||||
localAppData: root,
|
||||
appVersion: '2.0.0',
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(path.dirname(obsolete.bunPath)), false);
|
||||
assert.ok(fs.existsSync(path.dirname(current.bunPath)));
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
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';
|
||||
import {
|
||||
envOf,
|
||||
existsSyncOf,
|
||||
pathModuleFor,
|
||||
platformOf,
|
||||
type CommonOptions,
|
||||
type WindowsPathOptions,
|
||||
} from './command-line-launcher-deps';
|
||||
|
||||
export { MANAGED_LAUNCHER_MARKER, shellQuote } from './posix-launcher-bootstrap';
|
||||
|
||||
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 managedLauncherContent(options: {
|
||||
platform: NodeJS.Platform;
|
||||
appPath: string;
|
||||
}): string {
|
||||
if (options.platform === 'win32') return windowsLauncherBootstrapContent(options.appPath);
|
||||
return posixLauncherBootstrapContent(options.appPath);
|
||||
}
|
||||
|
||||
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;
|
||||
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'),
|
||||
fingerprintPath: platformPath.join(directory, 'fingerprint'),
|
||||
appPathFile: platformPath.join(directory, 'app-path'),
|
||||
};
|
||||
}
|
||||
|
||||
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'), { force: true });
|
||||
} 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';
|
||||
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)
|
||||
)
|
||||
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);
|
||||
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 {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -124,3 +125,137 @@ 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',
|
||||
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);
|
||||
});
|
||||
|
||||
test('GUI updates defer recognized standalone launcher migration to app startup', async () => {
|
||||
const accessed: string[] = [];
|
||||
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 (targetPath) => {
|
||||
accessed.push(targetPath);
|
||||
},
|
||||
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.',
|
||||
deferred: true,
|
||||
});
|
||||
assert.deepEqual(accessed, ['/home/tester/.local/bin/subminer', '/home/tester/.local/bin']);
|
||||
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 () => {},
|
||||
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);
|
||||
});
|
||||
|
||||
test('GUI migration reports a protected launcher when its file or parent is not writable', async () => {
|
||||
const launcherPath = '/usr/local/bin/subminer';
|
||||
for (const protectedPath of [launcherPath, '/usr/local/bin']) {
|
||||
const result = await updateLauncherAtPath({
|
||||
launcherPath,
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: launcherHash,
|
||||
deferRecognizedLauncherUpdate: true,
|
||||
download: async () => {
|
||||
throw new Error('Protected launchers must not download a replacement.');
|
||||
},
|
||||
fs: {
|
||||
stat: async () => ({ isFile: () => true }),
|
||||
readFile: async () => Buffer.from('#!/usr/bin/env bun\n// SubMiner launcher\n'),
|
||||
access: async (targetPath) => {
|
||||
if (targetPath === protectedPath) throw new Error('EACCES');
|
||||
},
|
||||
writeFile: async () => {
|
||||
throw new Error('Protected launchers must not be written.');
|
||||
},
|
||||
chmod: async () => {},
|
||||
rename: async () => {},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 'protected', protectedPath);
|
||||
assert.equal(
|
||||
result.command,
|
||||
buildProtectedLauncherUpdateCommand('https://example.test/subminer', launcherPath),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -23,6 +24,8 @@ export interface LauncherUpdateResult {
|
||||
path?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
// Set when a writable legacy launcher was left for app startup to migrate.
|
||||
deferred?: boolean;
|
||||
}
|
||||
|
||||
export interface LauncherUpdateFileSystem {
|
||||
@@ -82,6 +85,7 @@ export async function updateLauncherAtPath(options: {
|
||||
assetUrl: string;
|
||||
expectedSha256: string;
|
||||
download: () => Promise<Buffer>;
|
||||
deferRecognizedLauncherUpdate?: boolean;
|
||||
fs?: LauncherUpdateFileSystem;
|
||||
}): Promise<LauncherUpdateResult> {
|
||||
const fsDeps = options.fs ?? defaultFs();
|
||||
@@ -96,6 +100,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',
|
||||
@@ -103,9 +114,9 @@ export async function updateLauncherAtPath(options: {
|
||||
message: 'Existing executable does not look like a SubMiner launcher.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await fsDeps.access(options.launcherPath);
|
||||
await fsDeps.access(path.dirname(options.launcherPath));
|
||||
} catch {
|
||||
return {
|
||||
status: 'protected',
|
||||
@@ -114,6 +125,15 @@ export async function updateLauncherAtPath(options: {
|
||||
};
|
||||
}
|
||||
|
||||
if (options.deferRecognizedLauncherUpdate) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
path: options.launcherPath,
|
||||
message: 'Launcher migration is deferred until the updated SubMiner app starts.',
|
||||
deferred: true,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await options.download();
|
||||
const actualSha256 = sha256(data);
|
||||
if (actualSha256 !== options.expectedSha256.toLowerCase()) {
|
||||
@@ -160,7 +180,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');
|
||||
@@ -184,5 +206,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy
|
||||
import { updateLauncherFromRelease } from './launcher-updater';
|
||||
import { notifyUpdateAvailable } from './update-notifications';
|
||||
import { createUpdateDialogPresenter } from './update-dialogs';
|
||||
import { createFileUpdateStateStore, createUpdateService } from './update-service';
|
||||
import {
|
||||
createFileUpdateStateStore,
|
||||
createUpdateService,
|
||||
takePendingLauncherMigrationPath,
|
||||
} from './update-service';
|
||||
import { updateSupportAssetsFromRelease } from './support-assets';
|
||||
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
|
||||
|
||||
@@ -38,6 +42,9 @@ export interface UpdateServiceRuntimeDeps {
|
||||
|
||||
export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
|
||||
getUpdateService: () => ReturnType<typeof createUpdateService>;
|
||||
takePendingLauncherMigrationPath: (
|
||||
refresh: Parameters<typeof takePendingLauncherMigrationPath>[1],
|
||||
) => Promise<string | undefined>;
|
||||
} {
|
||||
const updateStateStore = createFileUpdateStateStore(
|
||||
path.join(deps.userDataPath, 'update-state.json'),
|
||||
@@ -79,6 +86,7 @@ export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
|
||||
sha256Sums: sums,
|
||||
launcherPath,
|
||||
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
|
||||
deferRecognizedLauncherUpdate: true,
|
||||
});
|
||||
return runSupportAssetUpdatesForLauncherResult({
|
||||
launcherResult,
|
||||
@@ -152,8 +160,7 @@ export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
|
||||
getConfig: () => deps.getUpdatesConfig(),
|
||||
getCurrentVersion: () => app.getVersion(),
|
||||
now: () => Date.now(),
|
||||
readState: () => updateStateStore.readState(),
|
||||
writeState: (state) => updateStateStore.writeState(state),
|
||||
stateStore: updateStateStore,
|
||||
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
||||
shouldFetchReleaseMetadata: ({ request, appUpdate }) =>
|
||||
shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate, request),
|
||||
@@ -187,5 +194,9 @@ export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
|
||||
return updateService;
|
||||
}
|
||||
|
||||
return { getUpdateService };
|
||||
return {
|
||||
getUpdateService,
|
||||
takePendingLauncherMigrationPath: (refresh) =>
|
||||
takePendingLauncherMigrationPath(updateStateStore, refresh),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
|
||||
import { createUpdateService, type UpdateServiceDeps, type UpdateState } from './update-service';
|
||||
import {
|
||||
createUpdateService,
|
||||
createUpdateStateStore,
|
||||
takePendingLauncherMigrationPath,
|
||||
type UpdateServiceDeps,
|
||||
type UpdateState,
|
||||
} from './update-service';
|
||||
|
||||
function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
|
||||
let state: UpdateState = {};
|
||||
@@ -15,11 +21,13 @@ function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
|
||||
}),
|
||||
getCurrentVersion: () => '0.14.0',
|
||||
now: () => 1_000_000,
|
||||
readState: async () => state,
|
||||
writeState: async (nextState) => {
|
||||
state = nextState;
|
||||
calls.push(`state:${JSON.stringify(nextState)}`);
|
||||
},
|
||||
stateStore: createUpdateStateStore({
|
||||
readState: async () => state,
|
||||
writeState: async (nextState) => {
|
||||
state = nextState;
|
||||
calls.push(`state:${JSON.stringify(nextState)}`);
|
||||
},
|
||||
}),
|
||||
checkAppUpdate: async () => ({ available: false, version: '0.14.0' }),
|
||||
fetchLatestStableRelease: async () => ({
|
||||
tag_name: 'v0.14.0',
|
||||
@@ -286,7 +294,7 @@ test('concurrent update checks share one in-flight check', async () => {
|
||||
const first = service.checkForUpdates({ source: 'manual' });
|
||||
const second = service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
resolveCheck({ available: false, version: '0.14.0' });
|
||||
await Promise.all([first, second]);
|
||||
|
||||
@@ -307,7 +315,7 @@ test('manual install request does not reuse in-flight manual check', async () =>
|
||||
const manualCheck = service.checkForUpdates({ source: 'manual' });
|
||||
const manualInstall = service.checkForUpdates({ source: 'manual', installWhenAvailable: true });
|
||||
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(checkCount, 2);
|
||||
for (const resolve of resolveChecks) {
|
||||
resolve({ available: false, version: '0.14.0' });
|
||||
@@ -315,26 +323,32 @@ test('manual install request does not reuse in-flight manual check', async () =>
|
||||
await Promise.all([manualCheck, manualInstall]);
|
||||
});
|
||||
|
||||
test('manual update check does not reuse in-flight automatic check', async () => {
|
||||
test('manual update check does not reuse in-flight automatic check and preserves all state', async () => {
|
||||
let checkCount = 0;
|
||||
const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = [];
|
||||
const { deps } = createDeps({
|
||||
const { deps, getState } = createDeps({
|
||||
checkAppUpdate: () =>
|
||||
new Promise((resolve) => {
|
||||
checkCount += 1;
|
||||
resolveChecks.push(resolve);
|
||||
}),
|
||||
updateLauncher: async () => ({ status: 'skipped', path: '/x/subminer', deferred: true }),
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
const automatic = service.checkForUpdates({ source: 'automatic', force: true });
|
||||
const manual = service.checkForUpdates({ source: 'manual' });
|
||||
const manual = service.checkForUpdates({ source: 'manual', installWhenAvailable: true });
|
||||
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(checkCount, 2);
|
||||
for (const resolve of resolveChecks) {
|
||||
resolve({ available: false, version: '0.14.0' });
|
||||
resolve({ available: true, version: '0.15.0' });
|
||||
}
|
||||
await Promise.all([automatic, manual]);
|
||||
assert.deepEqual(getState(), {
|
||||
pendingLauncherMigrationPath: '/x/subminer',
|
||||
lastAutomaticCheckAt: 1_000_000,
|
||||
lastNotifiedVersion: '0.15.0',
|
||||
});
|
||||
});
|
||||
|
||||
test('manual update check passes selected GitHub release to launcher update', async () => {
|
||||
@@ -488,3 +502,85 @@ test('manual update check keeps current prerelease builds on configured stable c
|
||||
assert.equal(result.status, 'up-to-date');
|
||||
assert.deepEqual(calls, ['app:stable', 'fetch:stable', 'no-update:0.15.0-beta.3']);
|
||||
});
|
||||
|
||||
test('deferred launcher migration is persisted for the next app start', async () => {
|
||||
const launcherPath = '/home/tester/.local/bin/subminer';
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
|
||||
fetchLatestStableRelease: async () => ({
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
}),
|
||||
showUpdateAvailableDialog: async () => 'update',
|
||||
updateLauncher: async () => ({ status: 'skipped', path: launcherPath, deferred: true }),
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual', launcherPath });
|
||||
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.ok(
|
||||
calls.includes(`state:${JSON.stringify({ pendingLauncherMigrationPath: launcherPath })}`),
|
||||
);
|
||||
});
|
||||
|
||||
test('takePendingLauncherMigrationPath hands the path over exactly once', async () => {
|
||||
let state: UpdateState = {
|
||||
lastNotifiedVersion: '0.15.0',
|
||||
pendingLauncherMigrationPath: '/x/subminer',
|
||||
};
|
||||
const store = createUpdateStateStore({
|
||||
readState: async () => state,
|
||||
writeState: async (nextState: UpdateState) => {
|
||||
state = nextState;
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = async () => true;
|
||||
assert.deepEqual(
|
||||
await Promise.all([
|
||||
takePendingLauncherMigrationPath(store, refresh),
|
||||
takePendingLauncherMigrationPath(store, refresh),
|
||||
]),
|
||||
['/x/subminer', undefined],
|
||||
);
|
||||
assert.deepEqual(state, { lastNotifiedVersion: '0.15.0' });
|
||||
assert.equal(await takePendingLauncherMigrationPath(store, refresh), undefined);
|
||||
});
|
||||
|
||||
test('failed migration remains pending and acknowledgement preserves concurrent check state', async () => {
|
||||
const { deps, getState, setState } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
|
||||
});
|
||||
setState({ pendingLauncherMigrationPath: '/x/subminer' });
|
||||
await assert.rejects(
|
||||
takePendingLauncherMigrationPath(deps.stateStore, async () => {
|
||||
throw new Error('refresh failed');
|
||||
}),
|
||||
/refresh failed/,
|
||||
);
|
||||
assert.equal(getState().pendingLauncherMigrationPath, '/x/subminer');
|
||||
|
||||
const service = createUpdateService(deps);
|
||||
const check = service.checkForUpdates({ source: 'automatic' });
|
||||
await takePendingLauncherMigrationPath(deps.stateStore, async () => false);
|
||||
await check;
|
||||
assert.deepEqual(getState(), {
|
||||
pendingLauncherMigrationPath: '/x/subminer',
|
||||
lastAutomaticCheckAt: 1_000_000,
|
||||
lastNotifiedVersion: '0.15.0',
|
||||
});
|
||||
|
||||
const nextCheck = service.checkForUpdates({ source: 'automatic', force: true });
|
||||
assert.equal(
|
||||
await takePendingLauncherMigrationPath(deps.stateStore, async () => true),
|
||||
'/x/subminer',
|
||||
);
|
||||
await nextCheck;
|
||||
assert.deepEqual(getState(), {
|
||||
lastAutomaticCheckAt: 1_000_000,
|
||||
lastNotifiedVersion: '0.15.0',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { compareSemverLike, parseReleaseVersion } from './release-assets';
|
||||
export interface UpdateState {
|
||||
lastAutomaticCheckAt?: number;
|
||||
lastNotifiedVersion?: string;
|
||||
// Legacy launcher the last update left for the next app start to migrate.
|
||||
pendingLauncherMigrationPath?: string;
|
||||
}
|
||||
|
||||
export type UpdateCheckSource = 'manual' | 'automatic' | 'launcher';
|
||||
@@ -41,8 +43,7 @@ export interface UpdateServiceDeps {
|
||||
getConfig: () => Required<UpdatesConfig>;
|
||||
getCurrentVersion: () => string;
|
||||
now: () => number;
|
||||
readState: () => Promise<UpdateState>;
|
||||
writeState: (state: UpdateState) => Promise<void>;
|
||||
stateStore: ReturnType<typeof createUpdateStateStore>;
|
||||
checkAppUpdate: (channel: UpdateChannel) => Promise<AppUpdateMetadata>;
|
||||
shouldFetchReleaseMetadata?: (input: {
|
||||
request: UpdateCheckRequest;
|
||||
@@ -54,7 +55,7 @@ export interface UpdateServiceDeps {
|
||||
launcherPath?: string,
|
||||
channel?: UpdateChannel,
|
||||
release?: GitHubRelease | null,
|
||||
) => Promise<{ status: string; command?: string }>;
|
||||
) => Promise<{ status: string; command?: string; path?: string; deferred?: boolean }>;
|
||||
showNoUpdateDialog: (version: string) => Promise<void>;
|
||||
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
|
||||
showUpdateFailedDialog: (message: string) => Promise<void>;
|
||||
@@ -121,7 +122,7 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
const now = deps.now();
|
||||
const config = deps.getConfig();
|
||||
const channel = config.channel;
|
||||
const state = await deps.readState();
|
||||
const state = await deps.stateStore.transaction((store) => store.readState());
|
||||
const isAutomatic = request.source === 'automatic';
|
||||
|
||||
if (isAutomatic && !request.force && shouldSkipAutomaticCheck(config, state, now)) {
|
||||
@@ -150,15 +151,18 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
const latest = getBestLatestVersion(currentVersion, appUpdate, release);
|
||||
|
||||
if (isAutomatic) {
|
||||
const nextState: UpdateState = {
|
||||
...state,
|
||||
lastAutomaticCheckAt: now,
|
||||
};
|
||||
if (latest.available && state.lastNotifiedVersion !== latest.version) {
|
||||
await deps.notifyUpdateAvailable(latest.version);
|
||||
nextState.lastNotifiedVersion = latest.version;
|
||||
}
|
||||
await deps.writeState(nextState);
|
||||
await deps.stateStore.transaction(async (store) => {
|
||||
const currentState = await store.readState();
|
||||
const nextState: UpdateState = {
|
||||
...currentState,
|
||||
lastAutomaticCheckAt: now,
|
||||
};
|
||||
if (latest.available && currentState.lastNotifiedVersion !== latest.version) {
|
||||
await deps.notifyUpdateAvailable(latest.version);
|
||||
nextState.lastNotifiedVersion = latest.version;
|
||||
}
|
||||
await store.writeState(nextState);
|
||||
});
|
||||
}
|
||||
|
||||
if (!latest.available) {
|
||||
@@ -189,6 +193,12 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
if (launcherResult.status === 'protected' && launcherResult.command) {
|
||||
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
|
||||
}
|
||||
if (launcherResult.deferred && launcherResult.path) {
|
||||
const pendingLauncherMigrationPath = launcherResult.path;
|
||||
await deps.stateStore.transaction(async (store) => {
|
||||
await store.writeState({ ...(await store.readState()), pendingLauncherMigrationPath });
|
||||
});
|
||||
}
|
||||
|
||||
if (!appUpdateApplied) {
|
||||
await deps.showManualUpdateRequiredDialog(latest.version);
|
||||
@@ -237,11 +247,41 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
};
|
||||
}
|
||||
|
||||
export function createFileUpdateStateStore(statePath: string): {
|
||||
// Keep the path until refresh acknowledges migration or an ineligible candidate.
|
||||
export async function takePendingLauncherMigrationPath(
|
||||
stateStore: ReturnType<typeof createUpdateStateStore>,
|
||||
refresh: (launcherPath: string | undefined) => Promise<boolean>,
|
||||
): Promise<string | undefined> {
|
||||
return stateStore.transaction(async (store) => {
|
||||
const { pendingLauncherMigrationPath, ...rest } = await store.readState();
|
||||
const acknowledged = await refresh(pendingLauncherMigrationPath);
|
||||
if (!pendingLauncherMigrationPath || !acknowledged) {
|
||||
return undefined;
|
||||
}
|
||||
await store.writeState(rest);
|
||||
return pendingLauncherMigrationPath;
|
||||
});
|
||||
}
|
||||
|
||||
export function createUpdateStateStore(store: {
|
||||
readState: () => Promise<UpdateState>;
|
||||
writeState: (state: UpdateState) => Promise<void>;
|
||||
} {
|
||||
}) {
|
||||
let pending = Promise.resolve();
|
||||
return {
|
||||
transaction<T>(operation: (stateStore: typeof store) => Promise<T>): Promise<T> {
|
||||
const result = pending.then(() => operation(store));
|
||||
pending = result.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createFileUpdateStateStore(statePath: string) {
|
||||
return createUpdateStateStore({
|
||||
async readState(): Promise<UpdateState> {
|
||||
try {
|
||||
return JSON.parse(await fs.promises.readFile(statePath, 'utf8')) as UpdateState;
|
||||
@@ -253,5 +293,5 @@ export function createFileUpdateStateStore(statePath: string): {
|
||||
await fs.promises.mkdir(path.dirname(statePath), { recursive: true });
|
||||
await fs.promises.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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%',
|
||||
'p%TEMP%q',
|
||||
'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');
|
||||
}
|
||||
@@ -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]*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user