feat: add auto update support

This commit is contained in:
2026-05-15 01:47:56 -07:00
parent d1ec678d7a
commit 094bcce0dc
101 changed files with 4978 additions and 163 deletions
+2
View File
@@ -43,6 +43,7 @@ export interface CliCommandRuntimeServiceContext {
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
@@ -118,6 +119,7 @@ function createCliCommandDepsFromContext(
app: {
stop: context.stopApp,
hasMainWindow: context.hasMainWindow,
runUpdateCommand: context.runUpdateCommand,
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
},
dispatchSessionAction: context.dispatchSessionAction,
+2
View File
@@ -184,6 +184,7 @@ export interface CliCommandRuntimeServiceDepsParams {
app: {
stop: CliCommandDepsRuntimeOptions['app']['stop'];
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
};
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
@@ -362,6 +363,7 @@ export function createCliCommandRuntimeServiceDeps(
app: {
stop: params.app.stop,
hasMainWindow: params.app.hasMainWindow,
runUpdateCommand: params.app.runUpdateCommand,
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
},
dispatchSessionAction: params.dispatchSessionAction,
@@ -68,9 +68,12 @@ test('open yomitan settings main deps map async open callbacks', async () => {
const calls: string[] = [];
let currentWindow: unknown = null;
const extension = { id: 'ext' };
const startupLoad = Promise.resolve(extension);
const yomitanSession = { id: 'session' };
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
ensureYomitanExtensionLoaded: async () => extension,
getYomitanExtension: () => extension,
getYomitanExtensionLoadInFlight: () => startupLoad,
openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) =>
calls.push(
`open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`,
@@ -86,6 +89,8 @@ test('open yomitan settings main deps map async open callbacks', async () => {
})();
assert.equal(await deps.ensureYomitanExtensionLoaded(), extension);
assert.equal(deps.getYomitanExtension?.(), extension);
assert.equal(deps.getYomitanExtensionLoadInFlight?.(), startupLoad);
assert.equal(deps.getExistingWindow(), null);
deps.setWindow({ id: 'win' });
deps.openYomitanSettingsWindow({
+11
View File
@@ -62,6 +62,8 @@ export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOpt
export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWindow>(deps: {
ensureYomitanExtensionLoaded: () => Promise<TYomitanExt | null>;
getYomitanExtension?: () => TYomitanExt | null;
getYomitanExtensionLoadInFlight?: () => Promise<unknown> | null;
openYomitanSettingsWindow: (params: {
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
@@ -77,6 +79,15 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
}) {
return () => ({
ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(),
...(deps.getYomitanExtension
? { getYomitanExtension: () => deps.getYomitanExtension?.() ?? null }
: {}),
...(deps.getYomitanExtensionLoadInFlight
? {
getYomitanExtensionLoadInFlight: () =>
deps.getYomitanExtensionLoadInFlight?.() ?? null,
}
: {}),
openYomitanSettingsWindow: (params: {
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
@@ -63,6 +63,9 @@ test('build cli command context deps maps handlers and values', () => {
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
runUpdateCommand: async () => {
calls.push('run-update');
},
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
@@ -41,6 +41,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
@@ -94,6 +95,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand,
runUpdateCommand: deps.runUpdateCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -71,6 +71,7 @@ test('cli command context factory composes main deps and context handlers', () =
}),
runStatsCommand: async () => {},
runJellyfinCommand: async () => {},
runUpdateCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
@@ -92,6 +92,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
runUpdateCommand: async () => {
calls.push('run-update');
},
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
@@ -1,4 +1,4 @@
import type { CliArgs } from '../../cli/args';
import type { CliArgs, CliCommandSource } from '../../cli/args';
import { resolveTexthookerWebsocketUrl } from '../../core/services/startup';
import type { CliCommandContextFactoryDeps } from './cli-command-context';
@@ -53,6 +53,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
@@ -121,6 +122,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
runUpdateCommand: (args: CliArgs, source: CliCommandSource) =>
deps.runUpdateCommand(args, source),
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
openYomitanSettings: () => deps.openYomitanSettings(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
@@ -53,6 +53,7 @@ function createDeps() {
}),
runStatsCommand: async () => {},
runJellyfinCommand: async () => {},
runUpdateCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
+2
View File
@@ -46,6 +46,7 @@ export type CliCommandContextFactoryDeps = {
setCharacterDictionarySelection?: CliCommandRuntimeServiceContext['setCharacterDictionarySelection'];
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
@@ -121,6 +122,7 @@ export function createCliCommandContext(
})),
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand,
runUpdateCommand: deps.runUpdateCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -0,0 +1,173 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
export interface RunCommandResult {
exitCode: number | null;
stdout: string;
stderr: string;
}
export type RunCommand = (
command: string,
args: string[],
options?: { timeoutMs?: number; env?: NodeJS.ProcessEnv },
) => Promise<RunCommandResult>;
export type FsDeps = {
existsSync?: (candidate: string) => boolean;
accessSync?: (candidate: string, mode?: number) => void;
mkdirSync?: (candidate: string, options?: { recursive?: boolean }) => unknown;
copyFileSync?: (from: string, to: string) => void;
writeFileSync?: (candidate: string, content: string, encoding?: BufferEncoding) => void;
readFileSync?: (candidate: string, encoding?: BufferEncoding) => string;
chmodSync?: (candidate: string, mode: number) => void;
};
export type CommonOptions = FsDeps & {
platform?: NodeJS.Platform;
env?: Record<string, string | undefined>;
homeDir?: string;
cwd?: string;
resourcesPath?: string;
appExePath?: string;
launcherResourcePath?: string;
runCommand?: RunCommand;
};
export type WindowsPathOptions = {
localAppData?: string;
userProfile?: string;
getUserPath?: () => string;
setUserPath?: (nextPath: string) => void | Promise<void>;
broadcastEnvironmentChange?: () => void | Promise<void>;
};
export function platformOf(options: CommonOptions): NodeJS.Platform {
return options.platform ?? process.platform;
}
export function envOf(options: CommonOptions): Record<string, string | undefined> {
return options.env ?? process.env;
}
export function pathModuleFor(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix;
}
export function existsSyncOf(options: FsDeps): (candidate: string) => boolean {
return options.existsSync ?? fs.existsSync;
}
export function accessSyncOf(options: FsDeps): (candidate: string, mode?: number) => void {
return options.accessSync ?? fs.accessSync;
}
export function splitPath(value: string | undefined, platform: NodeJS.Platform): string[] {
if (!value) return [];
const delimiter = platform === 'win32' ? ';' : ':';
return value
.split(delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
}
export function normalizePathForCompare(
candidate: string,
platform: NodeJS.Platform,
platformPath = pathModuleFor(platform),
): string {
const normalized = platformPath.normalize(candidate).replace(/[\\/]+$/, '');
return platform === 'win32' ? normalized.toLowerCase() : normalized;
}
export function pathEntriesContain(
entries: string[],
dir: string,
platform: NodeJS.Platform,
): boolean {
const normalizedDir = normalizePathForCompare(dir, platform);
return entries.some((entry) => normalizePathForCompare(entry, platform) === normalizedDir);
}
function isExecutableFile(candidate: string, options: CommonOptions): boolean {
try {
if (!existsSyncOf(options)(candidate)) return false;
if (options.existsSync && !options.accessSync) return true;
accessSyncOf(options)(candidate, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
export function findCommand(command: string, options: CommonOptions): string | null {
const platform = platformOf(options);
const platformPath = pathModuleFor(platform);
const entries = splitPath(envOf(options).PATH, platform);
const hasExtension = platformPath.extname(command) !== '';
const extensions =
platform === 'win32'
? hasExtension
? ['']
: (envOf(options).PATHEXT?.split(';').filter(Boolean) ?? [
'.exe',
'.cmd',
'.bat',
'.EXE',
'.CMD',
'.BAT',
])
: [''];
for (const entry of entries) {
for (const extension of extensions) {
const candidate = platformPath.join(entry, `${command}${extension}`);
if (isExecutableFile(candidate, options)) return candidate;
}
}
return null;
}
export function tail(value: string, max = 1200): string {
const clean = value.trim();
return clean.length > max ? clean.slice(clean.length - max) : clean;
}
export function failureMessage(result: RunCommandResult, fallback: string): string {
const detail = tail(result.stderr || result.stdout);
return detail ? `${fallback}: ${detail}` : fallback;
}
function createDefaultRunCommand(): RunCommand {
return (command, args, options = {}) =>
new Promise((resolve) => {
const child = spawn(command, args, {
env: options.env ?? process.env,
windowsHide: false,
});
let stdout = '';
let stderr = '';
const timeout = setTimeout(() => {
child.kill();
}, options.timeoutMs ?? 15_000);
child.stdout?.on('data', (chunk) => {
stdout = tail(stdout + String(chunk), 4000);
});
child.stderr?.on('data', (chunk) => {
stderr = tail(stderr + String(chunk), 4000);
});
child.on('error', (error) => {
clearTimeout(timeout);
resolve({ exitCode: 1, stdout, stderr: error.message });
});
child.on('close', (code) => {
clearTimeout(timeout);
resolve({ exitCode: code, stdout, stderr });
});
});
}
export function getRunCommand(options: CommonOptions): RunCommand {
return options.runCommand ?? createDefaultRunCommand();
}
@@ -0,0 +1,101 @@
import os from 'node:os';
import path from 'node:path';
import {
envOf,
getRunCommand,
pathEntriesContain,
splitPath,
type CommonOptions,
type WindowsPathOptions,
} from './command-line-launcher-deps';
const WINDOWS_PATH_MAX = 32767;
export function windowsLauncherPaths(options: CommonOptions & WindowsPathOptions): {
binDir: string;
installPath: string;
} {
const localAppData = options.localAppData ?? envOf(options).LOCALAPPDATA;
const base = localAppData ?? path.win32.join(options.homeDir ?? os.homedir(), 'AppData', 'Local');
const binDir = path.win32.join(base, 'SubMiner', 'bin');
return { binDir, installPath: path.win32.join(binDir, 'subminer.cmd') };
}
export function windowsShimContent(appExePath: string, launcherResourcePath: string): string {
return [
'@echo off',
'setlocal',
`set "SUBMINER_BINARY_PATH=${appExePath}"`,
`bun "${launcherResourcePath}" %*`,
'',
].join('\r\n');
}
export function shimMatchesCurrentInstall(
content: string,
appExePath: string,
launcherResourcePath: string,
): boolean {
const normalized = content.replaceAll('/', '\\').toLowerCase();
return (
normalized.includes(appExePath.replaceAll('/', '\\').toLowerCase()) &&
normalized.includes(launcherResourcePath.replaceAll('/', '\\').toLowerCase())
);
}
export function getUserPath(options: CommonOptions & WindowsPathOptions): string {
return options.getUserPath?.() ?? envOf(options).Path ?? envOf(options).PATH ?? '';
}
async function setWindowsUserPath(
options: CommonOptions & WindowsPathOptions,
nextPath: string,
) {
if (options.setUserPath) {
await options.setUserPath(nextPath);
return;
}
const escaped = nextPath.replaceAll("'", "''");
await getRunCommand(options)('powershell', [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
`[Environment]::SetEnvironmentVariable('Path', '${escaped}', 'User')`,
]);
}
async function broadcastEnvironmentChange(options: CommonOptions & WindowsPathOptions) {
if (options.broadcastEnvironmentChange) {
await options.broadcastEnvironmentChange();
return;
}
await getRunCommand(options)('powershell', [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
"$signature='[DllImport(\"user32.dll\",SetLastError=true,CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd,uint Msg,UIntPtr wParam,string lParam,uint fuFlags,uint uTimeout,out UIntPtr lpdwResult);'; Add-Type -MemberDefinition $signature -Name NativeMethods -Namespace Win32; $result=[UIntPtr]::Zero; [Win32.NativeMethods]::SendMessageTimeout([IntPtr]0xffff,0x1A,[UIntPtr]::Zero,'Environment',2,5000,[ref]$result) | Out-Null",
]);
}
export async function appendWindowsUserPathDir(
dir: string,
options: CommonOptions & WindowsPathOptions,
): Promise<string | null> {
const current = getUserPath(options);
const entries = splitPath(current, 'win32');
if (pathEntriesContain(entries, dir, 'win32')) return null;
const next = current.trim() ? `${current};${dir}` : dir;
if (next.length > WINDOWS_PATH_MAX) {
throw new Error('User PATH is too long to append the SubMiner launcher directory safely.');
}
await setWindowsUserPath(options, next);
await broadcastEnvironmentChange(options);
return next;
}
export function defaultBunRepairPath(options: CommonOptions & WindowsPathOptions): string {
const userProfile = options.userProfile ?? envOf(options).USERPROFILE ?? options.homeDir ?? os.homedir();
return path.win32.join(userProfile, '.bun', 'bin');
}
@@ -0,0 +1,269 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import {
detectBun,
detectLauncher,
installLauncher,
resolveBunInstallCommand,
resolveLauncherInstallTarget,
type BunSnapshot,
} from './command-line-launcher';
function createBunSnapshot(status: BunSnapshot['status']): BunSnapshot {
return {
status,
commandPath: status === 'ready' ? '/bin/bun' : null,
version: status === 'ready' ? '1.3.0' : null,
installMethod: null,
installCommand: null,
message: null,
};
}
test('detectBun reports ready when bun --version succeeds on PATH', async () => {
const snapshot = await detectBun({
platform: 'linux',
env: { PATH: '/usr/local/bin:/usr/bin' },
existsSync: (candidate) => candidate === '/usr/local/bin/bun',
accessSync: (candidate) => {
if (candidate !== '/usr/local/bin/bun') throw new Error('not executable');
},
runCommand: async (command, args) => {
assert.equal(command, '/usr/local/bin/bun');
assert.deepEqual(args, ['--version']);
return { exitCode: 0, stdout: '1.3.5\n', stderr: '' };
},
});
assert.deepEqual(snapshot, {
status: 'ready',
commandPath: '/usr/local/bin/bun',
version: '1.3.5',
installMethod: null,
installCommand: null,
message: null,
});
});
test('detectBun reports missing with an install command when bun is absent', async () => {
const snapshot = await detectBun({
platform: 'linux',
env: { PATH: '/usr/bin' },
existsSync: () => false,
accessSync: () => {
throw new Error('missing');
},
runCommand: async () => ({ exitCode: 127, stdout: '', stderr: 'missing' }),
});
assert.equal(snapshot.status, 'missing');
assert.equal(snapshot.commandPath, null);
assert.deepEqual(snapshot.installCommand, [
'bash',
'-lc',
'curl -fsSL https://bun.com/install | bash',
]);
});
test('resolveBunInstallCommand prefers winget on Windows', () => {
assert.deepEqual(
resolveBunInstallCommand({
platform: 'win32',
env: { PATH: 'C:\\Tools' },
existsSync: (candidate) => candidate === 'C:\\Tools\\winget.exe',
}),
[
'C:\\Tools\\winget.exe',
'install',
'--id',
'Oven-sh.Bun',
'--exact',
'--accept-package-agreements',
'--accept-source-agreements',
],
);
});
test('resolveBunInstallCommand falls back to scoop on Windows before official installer', () => {
assert.deepEqual(
resolveBunInstallCommand({
platform: 'win32',
env: { PATH: 'C:\\Tools' },
existsSync: (candidate) => candidate === 'C:\\Tools\\scoop.cmd',
}),
['C:\\Tools\\scoop.cmd', 'install', 'bun'],
);
});
test('resolveBunInstallCommand uses Homebrew on macOS when available', () => {
assert.deepEqual(
resolveBunInstallCommand({
platform: 'darwin',
env: { PATH: '/opt/homebrew/bin:/usr/bin' },
existsSync: (candidate) => candidate === '/opt/homebrew/bin/brew',
accessSync: (candidate) => {
if (candidate !== '/opt/homebrew/bin/brew') throw new Error('not executable');
},
}),
['/opt/homebrew/bin/brew', 'install', 'bun'],
);
});
test('resolveLauncherInstallTarget prefers writable user bin on Linux', async () => {
const target = await resolveLauncherInstallTarget({
platform: 'linux',
homeDir: '/home/tester',
env: { PATH: '/usr/bin:/home/tester/.local/bin:/tmp/bin' },
existsSync: (candidate) =>
candidate === '/usr/bin' ||
candidate === '/home/tester/.local/bin' ||
candidate === '/tmp/bin',
accessSync: (candidate) => {
if (candidate !== '/home/tester/.local/bin') throw new Error('not writable');
},
});
assert.equal(target.status, 'not_installed');
assert.equal(target.pathDir, '/home/tester/.local/bin');
assert.equal(target.installPath, '/home/tester/.local/bin/subminer');
});
test('resolveLauncherInstallTarget returns not_installable without writable PATH dirs', async () => {
const target = await resolveLauncherInstallTarget({
platform: 'linux',
homeDir: '/home/tester',
env: { PATH: '/usr/bin' },
existsSync: (candidate) => candidate === '/usr/bin',
accessSync: () => {
throw new Error('not writable');
},
});
assert.equal(target.status, 'not_installable');
assert.equal(target.installPath, null);
});
test('installLauncher writes Windows cmd shim and appends user PATH once', async () => {
const files = new Map<string, string>();
const dirs = new Set<string>();
const launcherResource = 'C:\\Apps\\SubMiner\\resources\\launcher\\subminer';
files.set(launcherResource, 'launcher');
let userPath = 'C:\\Tools;C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin';
let setPathCalls = 0;
const snapshot = await installLauncher({
platform: 'win32',
localAppData: 'C:\\Users\\tester\\AppData\\Local',
appExePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
launcherResourcePath: launcherResource,
env: { PATH: userPath },
existsSync: (candidate) => files.has(candidate) || dirs.has(candidate),
mkdirSync: (candidate) => dirs.add(candidate),
readFileSync: (candidate) => files.get(candidate) ?? '',
writeFileSync: (candidate, content) => files.set(candidate, content),
getUserPath: () => userPath,
setUserPath: (next) => {
setPathCalls += 1;
userPath = next;
},
broadcastEnvironmentChange: () => undefined,
runCommand: async (command, args) => {
if (command.endsWith('subminer.cmd') && args[0] === '--help') {
return { exitCode: 0, stdout: 'help', stderr: '' };
}
return { exitCode: 1, stdout: '', stderr: 'unexpected' };
},
bunSnapshot: createBunSnapshot('ready'),
});
const shimPath = 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin\\subminer.cmd';
assert.equal(setPathCalls, 0);
assert.match(
files.get(shimPath) ?? '',
/set "SUBMINER_BINARY_PATH=C:\\Apps\\SubMiner\\SubMiner\.exe"/,
);
assert.match(
files.get(shimPath) ?? '',
/bun "C:\\Apps\\SubMiner\\resources\\launcher\\subminer" %\*/,
);
assert.equal(snapshot.status, 'ready');
});
test('detectLauncher reports shadowed when another subminer appears earlier on PATH', async () => {
const snapshot = await detectLauncher({
platform: 'linux',
homeDir: '/home/tester',
env: { PATH: '/tmp/bin:/home/tester/.local/bin' },
existsSync: (candidate) =>
candidate === '/tmp/bin' ||
candidate === '/home/tester/.local/bin' ||
candidate === '/tmp/bin/subminer' ||
candidate === '/home/tester/.local/bin/subminer',
accessSync: () => undefined,
bunSnapshot: createBunSnapshot('ready'),
});
assert.equal(snapshot.status, 'shadowed');
assert.equal(snapshot.shadowedBy, '/tmp/bin/subminer');
assert.equal(snapshot.installPath, '/home/tester/.local/bin/subminer');
});
test('detectLauncher reports installed_bun_missing when launcher exists but bun is missing', async () => {
const snapshot = await detectLauncher({
platform: 'linux',
homeDir: '/home/tester',
env: { PATH: '/home/tester/.local/bin' },
existsSync: (candidate) =>
candidate === '/home/tester/.local/bin' || candidate === '/home/tester/.local/bin/subminer',
accessSync: () => undefined,
bunSnapshot: createBunSnapshot('missing'),
});
assert.equal(snapshot.status, 'installed_bun_missing');
});
test('detectLauncher treats stale Windows shim as not installed', async () => {
const shimPath = 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin\\subminer.cmd';
const snapshot = await detectLauncher({
platform: 'win32',
localAppData: 'C:\\Users\\tester\\AppData\\Local',
appExePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
launcherResourcePath: 'C:\\Apps\\SubMiner\\resources\\launcher\\subminer',
env: { PATH: 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin' },
existsSync: (candidate) =>
candidate === shimPath || candidate === 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin',
readFileSync: () =>
'@echo off\nset "SUBMINER_BINARY_PATH=C:\\Old\\SubMiner.exe"\nbun "C:\\Old\\launcher\\subminer" %*\n',
bunSnapshot: createBunSnapshot('ready'),
});
assert.equal(snapshot.status, 'not_installed');
assert.match(snapshot.message ?? '', /previous SubMiner install/);
});
test('installLauncher copies packaged launcher and chmods on POSIX', async () => {
const files = new Map<string, string>([['/resources/launcher/subminer', 'launcher']]);
const modes = new Map<string, number>();
const snapshot = await installLauncher({
platform: 'linux',
homeDir: '/home/tester',
env: { PATH: '/home/tester/.local/bin' },
launcherResourcePath: '/resources/launcher/subminer',
existsSync: (candidate) => files.has(candidate) || candidate === '/home/tester/.local/bin',
accessSync: () => undefined,
copyFileSync: (from, to) => files.set(to, files.get(from) ?? ''),
chmodSync: (candidate, mode) => modes.set(candidate, mode),
runCommand: async (command, args) => {
assert.equal(command, '/home/tester/.local/bin/subminer');
assert.deepEqual(args, ['--help']);
return { exitCode: 0, stdout: 'help', stderr: '' };
},
bunSnapshot: createBunSnapshot('ready'),
});
assert.equal(files.get('/home/tester/.local/bin/subminer'), 'launcher');
assert.equal(modes.get('/home/tester/.local/bin/subminer'), 0o755);
assert.equal(snapshot.status, 'ready');
});
+444
View File
@@ -0,0 +1,444 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
accessSyncOf,
envOf,
existsSyncOf,
failureMessage,
findCommand,
getRunCommand,
normalizePathForCompare,
pathEntriesContain,
pathModuleFor,
platformOf,
splitPath,
type CommonOptions,
type WindowsPathOptions,
} from './command-line-launcher-deps';
import {
appendWindowsUserPathDir,
defaultBunRepairPath,
shimMatchesCurrentInstall,
windowsLauncherPaths,
windowsShimContent,
} from './command-line-launcher-windows';
export type { RunCommand, RunCommandResult } from './command-line-launcher-deps';
export type ToolStatus = 'ready' | 'missing' | 'installing' | 'failed';
export type LauncherInstallStatus =
| 'ready'
| 'installed_bun_missing'
| 'not_installed'
| 'not_on_path'
| 'shadowed'
| 'not_installable'
| 'failed';
export interface BunSnapshot {
status: ToolStatus;
commandPath: string | null;
version: string | null;
installMethod: 'winget' | 'scoop' | 'homebrew' | 'official-script' | null;
installCommand: string[] | null;
message: string | null;
}
export interface LauncherSnapshot {
status: LauncherInstallStatus;
commandPath: string | null;
installPath: string | null;
pathDir: string | null;
shadowedBy: string | null;
message: string | null;
}
export interface CommandLineLauncherSnapshot {
supported: boolean;
bun: BunSnapshot;
launcher: LauncherSnapshot;
}
const BUN_OFFICIAL_POSIX_COMMAND = ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'];
const BUN_OFFICIAL_WINDOWS_COMMAND = [
'powershell',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
'irm bun.sh/install.ps1 | iex',
];
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
const COMMAND_TIMEOUT_MS = 15 * 1000;
function installMethodForCommand(
command: string[] | null,
): BunSnapshot['installMethod'] {
if (!command) return null;
const executablePath = command[0];
if (!executablePath) return null;
const executable = path.win32.basename(executablePath).toLowerCase();
if (executable === 'winget.exe') return 'winget';
if (executable === 'scoop.cmd') return 'scoop';
if (executable === 'brew') return 'homebrew';
return 'official-script';
}
export function resolveBunInstallCommand(options: CommonOptions = {}): BunSnapshot['installCommand'] {
const platform = platformOf(options);
if (platform === 'win32') {
const winget = findCommand('winget.exe', options);
if (winget) {
return [
winget,
'install',
'--id',
'Oven-sh.Bun',
'--exact',
'--accept-package-agreements',
'--accept-source-agreements',
];
}
const scoop = findCommand('scoop.cmd', options);
if (scoop) return [scoop, 'install', 'bun'];
return BUN_OFFICIAL_WINDOWS_COMMAND;
}
const brew = findCommand('brew', options);
if (platform === 'darwin' && brew) return [brew, 'install', 'bun'];
if (platform === 'linux' && brew) return [brew, 'install', 'bun'];
return BUN_OFFICIAL_POSIX_COMMAND;
}
export async function detectBun(options: CommonOptions = {}): Promise<BunSnapshot> {
const bunPath = findCommand('bun', options);
const installCommand = resolveBunInstallCommand(options);
if (!bunPath) {
return {
status: 'missing',
commandPath: null,
version: null,
installMethod: installMethodForCommand(installCommand),
installCommand,
message: null,
};
}
const result = await getRunCommand(options)(bunPath, ['--version'], {
timeoutMs: COMMAND_TIMEOUT_MS,
env: envOf(options) as NodeJS.ProcessEnv,
});
if (result.exitCode === 0) {
return {
status: 'ready',
commandPath: bunPath,
version: result.stdout.trim() || null,
installMethod: null,
installCommand: null,
message: null,
};
}
return {
status: 'failed',
commandPath: bunPath,
version: null,
installMethod: installMethodForCommand(installCommand),
installCommand,
message: failureMessage(result, 'bun --version failed'),
};
}
function resolveLauncherResourcePath(options: CommonOptions): string {
const platformPath = pathModuleFor(platformOf(options));
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;
if (packaged && existsSyncOf(options)(packaged)) return packaged;
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer');
}
function isWritableDir(candidate: string, options: CommonOptions): boolean {
try {
if (!existsSyncOf(options)(candidate)) return false;
accessSyncOf(options)(candidate, fs.constants.W_OK);
return true;
} catch {
return false;
}
}
function collectPathDirs(options: CommonOptions): string[] {
const platform = platformOf(options);
const dirs: string[] = [];
const add = (dir: string) => {
if (!pathEntriesContain(dirs, dir, platform)) dirs.push(dir);
};
splitPath(envOf(options).PATH, platform).forEach(add);
return dirs;
}
export async function resolveLauncherInstallTarget(
options: CommonOptions & WindowsPathOptions = {},
): Promise<LauncherSnapshot> {
const platform = platformOf(options);
if (platform === 'win32') {
const { binDir, installPath } = windowsLauncherPaths(options);
return {
status: existsSyncOf(options)(installPath) ? 'ready' : 'not_installed',
commandPath: existsSyncOf(options)(installPath) ? installPath : null,
installPath,
pathDir: binDir,
shadowedBy: null,
message: null,
};
}
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 candidates = [...preferred, ...pathDirs].filter((dir, index, all) =>
all.findIndex((other) => normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform)) === index,
);
const selected = candidates.find((dir) => pathEntriesContain(pathDirs, dir, platform) && isWritableDir(dir, options));
if (!selected) {
return {
status: 'not_installable',
commandPath: null,
installPath: null,
pathDir: null,
shadowedBy: null,
message: 'No writable directory was found on your command-line PATH.',
};
}
const installPath = path.posix.join(selected, 'subminer');
return {
status: existsSyncOf(options)(installPath) ? 'ready' : 'not_installed',
commandPath: existsSyncOf(options)(installPath) ? installPath : null,
installPath,
pathDir: selected,
shadowedBy: null,
message: null,
};
}
export async function detectLauncher(
options: CommonOptions & WindowsPathOptions & { bunSnapshot?: BunSnapshot } = {},
): Promise<LauncherSnapshot> {
const platform = platformOf(options);
const target = await resolveLauncherInstallTarget(options);
if (target.status === 'not_installable') return target;
const expectedPath = target.installPath;
if (!expectedPath) return target;
const platformPath = pathModuleFor(platform);
const launcherResourcePath = resolveLauncherResourcePath(options);
const appExePath = options.appExePath ?? process.execPath;
if (platform === 'win32' && existsSyncOf(options)(expectedPath)) {
const content = String((options.readFileSync ?? fs.readFileSync)(expectedPath, 'utf8'));
if (!shimMatchesCurrentInstall(content, appExePath, launcherResourcePath)) {
return {
...target,
status: 'not_installed',
commandPath: null,
message: 'Installed launcher points at a previous SubMiner install; reinstall to refresh.',
};
}
}
const commandPath = findCommand('subminer', options);
const expectedNormalized = normalizePathForCompare(expectedPath, platform, platformPath);
if (commandPath && normalizePathForCompare(commandPath, platform, platformPath) !== expectedNormalized) {
return { ...target, status: 'shadowed', commandPath: expectedPath, shadowedBy: commandPath };
}
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.',
};
}
const result = await getRunCommand(options)(commandPath, ['--help'], {
timeoutMs: COMMAND_TIMEOUT_MS,
env: envOf(options) as NodeJS.ProcessEnv,
});
if (result.exitCode !== 0) {
return {
...target,
status: 'failed',
commandPath,
message: failureMessage(result, 'subminer --help failed'),
};
}
return { ...target, status: 'ready', commandPath, message: null };
}
export async function installLauncher(
options: CommonOptions & WindowsPathOptions & { bunSnapshot?: BunSnapshot } = {},
): Promise<LauncherSnapshot> {
const platform = platformOf(options);
const target = await resolveLauncherInstallTarget(options);
if (!target.installPath || !target.pathDir) return target;
const launcherResourcePath = resolveLauncherResourcePath(options);
if (!existsSyncOf(options)(launcherResourcePath)) {
return {
...target,
status: 'failed',
message: `Packaged launcher resource is missing: ${launcherResourcePath}`,
};
}
if (platform === 'win32') {
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
(options.writeFileSync ?? fs.writeFileSync)(
target.installPath,
windowsShimContent(options.appExePath ?? process.execPath, launcherResourcePath),
'utf8',
);
try {
const nextPath = await appendWindowsUserPathDir(target.pathDir, options);
if (nextPath && options.env) {
options.env.PATH = nextPath;
options.env.Path = nextPath;
}
} catch (error) {
return {
...target,
status: 'failed',
message: error instanceof Error ? error.message : String(error),
};
}
} else {
(options.copyFileSync ?? fs.copyFileSync)(launcherResourcePath, target.installPath);
(options.chmodSync ?? fs.chmodSync)(target.installPath, 0o755);
}
return detectLauncher(options);
}
export async function installBun(
options: CommonOptions & WindowsPathOptions = {},
): Promise<BunSnapshot> {
const platform = platformOf(options);
if (platform === 'win32') {
const bunDir = defaultBunRepairPath(options);
const bunExe = path.win32.join(bunDir, 'bun.exe');
if (existsSyncOf(options)(bunExe) && !findCommand('bun.exe', options)) {
try {
await appendWindowsUserPathDir(bunDir, options);
return {
status: 'ready',
commandPath: bunExe,
version: null,
installMethod: null,
installCommand: null,
message: 'Bun PATH repaired. Open a new terminal.',
};
} catch (error) {
return {
status: 'failed',
commandPath: bunExe,
version: null,
installMethod: null,
installCommand: null,
message: error instanceof Error ? error.message : String(error),
};
}
}
}
const installCommand = resolveBunInstallCommand(options);
if (!installCommand || installCommand.length === 0) {
return {
status: 'missing',
commandPath: null,
version: null,
installMethod: null,
installCommand: null,
message: 'No Bun install command is available for this platform.',
};
}
const command = installCommand[0]!;
const args = installCommand.slice(1);
const result = await getRunCommand(options)(command, args, {
timeoutMs: INSTALL_TIMEOUT_MS,
env: envOf(options) as NodeJS.ProcessEnv,
});
if (result.exitCode !== 0) {
return {
status: 'failed',
commandPath: null,
version: null,
installMethod: installMethodForCommand(installCommand),
installCommand,
message: failureMessage(result, 'Bun install failed'),
};
}
const detected = await detectBun(options);
if (detected.status === 'ready') {
return { ...detected, message: 'Bun installed. Open a new terminal.' };
}
return {
...detected,
status: 'missing',
message:
platform === 'win32'
? 'Bun installed, but this process cannot see it on PATH yet. Open a new terminal.'
: 'Bun installed, but is not on PATH for this shell. Add ~/.bun/bin to PATH if needed.',
};
}
export async function detectCommandLineLauncher(
options: CommonOptions & WindowsPathOptions = {},
): Promise<CommandLineLauncherSnapshot> {
const platform = platformOf(options);
const supported = platform === 'win32' || platform === 'linux' || platform === 'darwin';
if (!supported) {
return {
supported: false,
bun: {
status: 'missing',
commandPath: null,
version: null,
installMethod: null,
installCommand: null,
message: 'Command-line launcher setup is not supported on this platform.',
},
launcher: {
status: 'not_installable',
commandPath: null,
installPath: null,
pathDir: null,
shadowedBy: null,
message: 'Command-line launcher setup is not supported on this platform.',
},
};
}
const bun = await detectBun(options);
const launcher = await detectLauncher({ ...options, bunSnapshot: bun });
return { supported, bun, launcher };
}
@@ -47,6 +47,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
}),
runJellyfinCommand: async () => {},
runStatsCommand: async () => {},
runUpdateCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
@@ -5,6 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup } from './first-run-setup-service';
import type { CliArgs } from '../../cli/args';
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-'));
@@ -90,6 +91,31 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
};
}
function createCommandLineLauncherSnapshot(
overrides: Partial<CommandLineLauncherSnapshot> = {},
): CommandLineLauncherSnapshot {
return {
supported: true,
bun: {
status: 'missing',
commandPath: null,
version: null,
installMethod: 'official-script',
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
message: null,
},
launcher: {
status: 'not_installed',
commandPath: null,
installPath: '/home/tester/.local/bin/subminer',
pathDir: '/home/tester/.local/bin',
shadowedBy: null,
message: null,
},
...overrides,
};
}
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
@@ -514,6 +540,141 @@ test('setup service persists Windows mpv shortcut preferences and status with on
});
});
test('setup service snapshot includes command-line launcher status', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
const commandLineLauncher = createCommandLineLauncherSnapshot({
bun: {
status: 'ready',
commandPath: '/usr/local/bin/bun',
version: '1.3.5',
installMethod: null,
installCommand: null,
message: null,
},
});
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => true,
detectCommandLineLauncher: async () => commandLineLauncher,
onStateChanged: () => undefined,
});
const snapshot = await service.refreshStatus();
assert.deepEqual(snapshot.commandLineLauncher, commandLineLauncher);
});
});
test('setup service installBun persists installed and failed status', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let installOk = true;
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => true,
detectCommandLineLauncher: async () => createCommandLineLauncherSnapshot(),
installBun: async () => ({
ok: installOk,
message: installOk ? 'Bun installed. Open a new terminal.' : 'Bun install failed.',
}),
onStateChanged: () => undefined,
});
const installed = await service.installBun();
assert.equal(installed.state.bunInstallStatus, 'installed');
assert.equal(installed.canFinish, true);
assert.equal(installed.message, 'Bun installed. Open a new terminal.');
installOk = false;
const failed = await service.installBun();
assert.equal(failed.state.bunInstallStatus, 'failed');
assert.equal(failed.canFinish, true);
assert.equal(failed.message, 'Bun install failed.');
});
});
test('setup service installCommandLineLauncher persists status and path', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let installOk = true;
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => true,
detectCommandLineLauncher: async () => createCommandLineLauncherSnapshot(),
installCommandLineLauncher: async () => ({
ok: installOk,
installPath: installOk ? '/home/tester/.local/bin/subminer' : null,
message: installOk ? 'Launcher installed.' : 'Launcher install failed.',
}),
onStateChanged: () => undefined,
});
const installed = await service.installCommandLineLauncher();
assert.equal(installed.state.launcherInstallStatus, 'installed');
assert.equal(installed.state.launcherInstallPath, '/home/tester/.local/bin/subminer');
assert.equal(installed.canFinish, true);
installOk = false;
const failed = await service.installCommandLineLauncher();
assert.equal(failed.state.launcherInstallStatus, 'failed');
assert.equal(failed.state.launcherInstallPath, null);
assert.equal(failed.canFinish, true);
});
});
test('setup completion is unaffected by missing or failed command-line launcher setup', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => true,
detectCommandLineLauncher: async () =>
createCommandLineLauncherSnapshot({
bun: {
status: 'failed',
commandPath: null,
version: null,
installMethod: 'official-script',
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
message: 'Bun install failed.',
},
launcher: {
status: 'failed',
commandPath: null,
installPath: '/home/tester/.local/bin/subminer',
pathDir: '/home/tester/.local/bin',
shadowedBy: null,
message: 'Launcher install failed.',
},
}),
onStateChanged: () => undefined,
});
const initial = await service.ensureSetupStateInitialized();
assert.equal(initial.canFinish, true);
const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.equal(completed.canFinish, true);
});
});
test('setup service removes legacy mpv plugin candidates and refreshes detection', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
@@ -15,6 +15,7 @@ import type {
InstalledFirstRunPluginCandidate,
LegacyMpvPluginRemovalResult,
} from './first-run-setup-plugin';
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
export interface SetupWindowsMpvShortcutSnapshot {
supported: boolean;
@@ -35,6 +36,7 @@ export interface SetupStatusSnapshot {
pluginInstallPathSummary: string | null;
legacyMpvPluginPaths: string[];
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
commandLineLauncher: CommandLineLauncherSnapshot;
message: string | null;
state: SetupState;
}
@@ -58,6 +60,8 @@ export interface FirstRunSetupService {
startMenuEnabled: boolean;
desktopEnabled: boolean;
}) => Promise<SetupStatusSnapshot>;
installBun: () => Promise<SetupStatusSnapshot>;
installCommandLineLauncher: () => Promise<SetupStatusSnapshot>;
isSetupCompleted: () => boolean;
}
@@ -172,6 +176,28 @@ function isYomitanSetupSatisfied(options: {
return options.externalYomitanConfigured || options.dictionaryCount >= 1;
}
function createUnsupportedCommandLineLauncherSnapshot(): CommandLineLauncherSnapshot {
return {
supported: false,
bun: {
status: 'missing',
commandPath: null,
version: null,
installMethod: null,
installCommand: null,
message: 'Command-line launcher setup is unavailable in this runtime.',
},
launcher: {
status: 'not_installable',
commandPath: null,
installPath: null,
pathDir: null,
shadowedBy: null,
message: 'Command-line launcher setup is unavailable in this runtime.',
},
};
}
export function getFirstRunSetupCompletionMessage(snapshot: {
configReady: boolean;
dictionaryCount: number;
@@ -235,6 +261,15 @@ export function createFirstRunSetupService(deps: {
startMenuEnabled: boolean;
desktopEnabled: boolean;
}) => Promise<{ ok: boolean; status: SetupWindowsMpvShortcutInstallStatus; message: string }>;
detectCommandLineLauncher?: () =>
| CommandLineLauncherSnapshot
| Promise<CommandLineLauncherSnapshot>;
installBun?: () => Promise<{ ok: boolean; message: string }>;
installCommandLineLauncher?: () => Promise<{
ok: boolean;
installPath: string | null;
message: string;
}>;
onStateChanged?: (state: SetupState) => void;
}): FirstRunSetupService {
const setupStatePath = getSetupStatePath(deps.configDir);
@@ -262,6 +297,8 @@ export function createFirstRunSetupService(deps: {
const detectedWindowsMpvShortcuts = isWindows
? await deps.detectWindowsMpvShortcuts?.()
: undefined;
const commandLineLauncher =
(await deps.detectCommandLineLauncher?.()) ?? createUnsupportedCommandLineLauncherSnapshot();
const installedWindowsMpvShortcuts = {
startMenuInstalled: detectedWindowsMpvShortcuts?.startMenuInstalled ?? false,
desktopInstalled: detectedWindowsMpvShortcuts?.desktopInstalled ?? false,
@@ -291,6 +328,7 @@ export function createFirstRunSetupService(deps: {
status: getWindowsMpvShortcutStatus(state, installedWindowsMpvShortcuts),
message: null,
},
commandLineLauncher,
message,
state,
} satisfies SetupStatusSnapshot;
@@ -453,6 +491,36 @@ export function createFirstRunSetupService(deps: {
result.message,
);
},
installBun: async () => {
if (!deps.installBun) {
return refreshWithState(readState(), 'Bun installation is unavailable in this runtime.');
}
const result = await deps.installBun();
return refreshWithState(
writeState({
...readState(),
bunInstallStatus: result.ok ? 'installed' : 'failed',
}),
result.message,
);
},
installCommandLineLauncher: async () => {
if (!deps.installCommandLineLauncher) {
return refreshWithState(
readState(),
'Command-line launcher installation is unavailable in this runtime.',
);
}
const result = await deps.installCommandLineLauncher();
return refreshWithState(
writeState({
...readState(),
launcherInstallStatus: result.ok ? 'installed' : 'failed',
launcherInstallPath: result.ok ? result.installPath : null,
}),
result.message,
);
},
isSetupCompleted: () => completed || isSetupCompleted(readState()),
};
}
@@ -7,6 +7,32 @@ import {
createOpenFirstRunSetupWindowHandler,
parseFirstRunSetupSubmissionUrl,
} from './first-run-setup-window';
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
function createCommandLineLauncherSnapshot(
overrides: Partial<CommandLineLauncherSnapshot> = {},
): CommandLineLauncherSnapshot {
return {
supported: true,
bun: {
status: 'missing',
commandPath: null,
version: null,
installMethod: 'official-script',
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
message: null,
},
launcher: {
status: 'not_installed',
commandPath: null,
installPath: '/home/tester/.local/bin/subminer',
pathDir: '/home/tester/.local/bin',
shadowedBy: null,
message: null,
},
...overrides,
};
}
test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish state', () => {
const html = buildFirstRunSetupHtml({
@@ -26,6 +52,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: 'Waiting for dictionaries',
});
@@ -58,6 +85,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
desktopInstalled: false,
status: 'installed',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
@@ -88,6 +116,7 @@ test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirm
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
@@ -124,6 +153,7 @@ test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', (
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
@@ -149,6 +179,7 @@ test('buildFirstRunSetupHtml explains the config blocker when setup is missing c
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
@@ -173,6 +204,7 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
@@ -196,6 +228,20 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh',
});
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=install-bun'),
{
action: 'install-bun',
},
);
assert.deepEqual(
parseFirstRunSetupSubmissionUrl(
'subminer://first-run-setup?action=install-command-line-launcher',
),
{
action: 'install-command-line-launcher',
},
);
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=remove-legacy-plugin'),
{
@@ -209,6 +255,59 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
});
test('buildFirstRunSetupHtml renders command-line launcher section and actions', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot({
bun: {
status: 'failed',
commandPath: null,
version: null,
installMethod: 'official-script',
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
message: 'network failed',
},
launcher: {
status: 'installed_bun_missing',
commandPath: '/home/tester/.local/bin/subminer',
installPath: '/home/tester/.local/bin/subminer',
pathDir: '/home/tester/.local/bin',
shadowedBy: null,
message: 'Bun is missing.',
},
}),
message: null,
});
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, /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, /\/home\/tester\/\.local\/bin\/subminer/);
assert.match(html, /action=install-command-line-launcher/);
assert.match(html, /<button class="primary" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/);
});
test('first-run setup window handler focuses existing window', () => {
const calls: string[] = [];
const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({
@@ -304,6 +403,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
}),
buildSetupHtml: () => '<html></html>',
+146
View File
@@ -1,4 +1,9 @@
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
import type {
BunSnapshot,
CommandLineLauncherSnapshot,
LauncherSnapshot,
} from './command-line-launcher';
type FocusableWindowLike = {
focus: () => void;
@@ -20,6 +25,8 @@ export type FirstRunSetupAction =
| 'configure-mpv-executable-path'
| 'remove-legacy-plugin'
| 'configure-windows-mpv-shortcuts'
| 'install-bun'
| 'install-command-line-launcher'
| 'open-yomitan-settings'
| 'refresh'
| 'finish';
@@ -49,6 +56,7 @@ export interface FirstRunSetupHtmlModel {
desktopInstalled: boolean;
status: 'installed' | 'optional' | 'skipped' | 'failed';
};
commandLineLauncher: CommandLineLauncherSnapshot;
message: string | null;
}
@@ -64,6 +72,125 @@ 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';
case 'not_installed':
return 'Not installed';
case 'not_on_path':
return 'Not on PATH';
case 'shadowed':
return 'Shadowed';
case 'not_installable':
return 'Not installable';
case 'failed':
return 'Failed';
}
}
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' {
if (status === 'ready') return 'ready';
if (status === 'failed') return 'danger';
if (status === 'installed_bun_missing' || status === 'not_installed') return 'warn';
return 'muted';
}
function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLauncherSnapshot): string {
if (!commandLineLauncher.supported) {
return '';
}
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 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,
].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 === 'failed' ? '' : '';
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>
<div class="card block">
<div class="card-head">
<div>
<strong>SubMiner launcher</strong>
${launcherMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
</div>
${renderStatusBadge(getLauncherStatusLabel(launcher.status), getLauncherTone(launcher.status))}
</div>
<div class="inline-actions">
<button ${launcherButtonDisabled} onclick="window.location.href='subminer://first-run-setup?action=install-command-line-launcher'">Install launcher</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
</div>
</div>
</section>`;
}
export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
const legacyMpvPluginPaths = model.legacyMpvPluginPaths ?? [];
const finishButtonLabel =
@@ -264,6 +391,16 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
gap: 8px;
margin-top: 12px;
}
.setup-section {
margin-top: 10px;
}
.section-head {
margin: 14px 0 8px;
}
.section-head h2 {
margin: 0;
font-size: 14px;
}
label {
color: var(--muted);
display: flex;
@@ -307,6 +444,12 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
gap: 8px;
margin-top: 14px;
}
.inline-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
button {
border: 0;
border-radius: 10px;
@@ -386,6 +529,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
${mpvExecutablePathCard}
${windowsShortcutCard}
${renderCommandLineLauncherSection(model.commandLineLauncher)}
${legacyPluginCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
@@ -409,6 +553,8 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
action !== 'configure-mpv-executable-path' &&
action !== 'remove-legacy-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
action !== 'install-bun' &&
action !== 'install-command-line-launcher' &&
action !== 'open-yomitan-settings' &&
action !== 'refresh' &&
action !== 'finish'
@@ -11,6 +11,9 @@ export function createBuildReloadConfigMainDepsHandler(deps: ReloadConfigMainDep
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
startConfigHotReload: () => deps.startConfigHotReload(),
shouldRefreshAnilistClientSecretState: deps.shouldRefreshAnilistClientSecretState
? () => deps.shouldRefreshAnilistClientSecretState?.() !== false
: undefined,
refreshAnilistClientSecretState: (options: { force: boolean }) =>
deps.refreshAnilistClientSecretState(options),
failHandlers: {
+30
View File
@@ -93,6 +93,36 @@ test('createReloadConfigHandler fails startup for parse errors', () => {
assert.equal(calls.includes('hotReload:start'), false);
});
test('createReloadConfigHandler can skip AniList refresh for headless commands', async () => {
const calls: string[] = [];
const reloadConfig = createReloadConfigHandler({
reloadConfigStrict: () => ({
ok: true,
path: '/tmp/config.jsonc',
warnings: [],
}),
logInfo: (message) => calls.push(`info:${message}`),
logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('hotReload:start'),
shouldRefreshAnilistClientSecretState: () => false,
refreshAnilistClientSecretState: async () => {
calls.push('refresh');
},
failHandlers: {
logError: (details) => calls.push(`error:${details}`),
showErrorBox: (title, details) => calls.push(`dialog:${title}:${details}`),
quit: () => calls.push('quit'),
},
});
reloadConfig();
await Promise.resolve();
assert.equal(calls.includes('refresh'), false);
assert.ok(calls.includes('hotReload:start'));
});
test('createCriticalConfigErrorHandler formats and fails', () => {
const calls: string[] = [];
const exitCodes: number[] = [];
+4 -1
View File
@@ -31,6 +31,7 @@ export type ReloadConfigRuntimeDeps = {
force: boolean;
allowSetupPrompt?: boolean;
}) => Promise<unknown>;
shouldRefreshAnilistClientSecretState?: () => boolean;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
@@ -75,7 +76,9 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () =>
}
deps.startConfigHotReload();
void deps.refreshAnilistClientSecretState({ force: true, allowSetupPrompt: false });
if (deps.shouldRefreshAnilistClientSecretState?.() !== false) {
void deps.refreshAnilistClientSecretState({ force: true, allowSetupPrompt: false });
}
};
}
+25 -1
View File
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldEnsureTrayOnStartupForInitialArgs } from './startup-tray-policy';
import {
shouldEnsureTrayOnStartupForInitialArgs,
shouldQuitOnWindowAllClosedForTrayState,
} from './startup-tray-policy';
test('startup tray policy enables tray on Windows by default', () => {
assert.equal(shouldEnsureTrayOnStartupForInitialArgs('win32', null), true);
@@ -18,3 +21,24 @@ test('startup tray policy skips tray for direct youtube playback on Windows', ()
test('startup tray policy skips tray outside Windows', () => {
assert.equal(shouldEnsureTrayOnStartupForInitialArgs('linux', null), false);
});
test('window-all-closed keeps tray-resident app alive', () => {
assert.equal(
shouldQuitOnWindowAllClosedForTrayState({ backgroundMode: false, hasTray: true }),
false,
);
});
test('window-all-closed quits non-background app without tray', () => {
assert.equal(
shouldQuitOnWindowAllClosedForTrayState({ backgroundMode: false, hasTray: false }),
true,
);
});
test('window-all-closed keeps background app alive without tray', () => {
assert.equal(
shouldQuitOnWindowAllClosedForTrayState({ backgroundMode: true, hasTray: false }),
false,
);
});
+9
View File
@@ -12,3 +12,12 @@ export function shouldEnsureTrayOnStartupForInitialArgs(
}
return true;
}
export function shouldQuitOnWindowAllClosedForTrayState(options: {
backgroundMode: boolean;
hasTray: boolean;
}): boolean {
if (options.backgroundMode) return false;
if (options.hasTray) return false;
return true;
}
@@ -43,6 +43,7 @@ test('build tray template handler wires actions and init guards', () => {
buildTrayMenuTemplateRuntime: (handlers) => {
handlers.openSessionHelp();
handlers.openTexthookerInBrowser();
calls.push(`show-texthooker:${handlers.showTexthookerPage}`);
handlers.openFirstRunSetup();
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
@@ -50,6 +51,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery();
handlers.openAnilistSetup();
handlers.checkForUpdates();
handlers.quitApp();
return [{ label: 'ok' }] as never;
},
@@ -60,6 +62,7 @@ test('build tray template handler wires actions and init guards', () => {
isOverlayRuntimeInitialized: () => initialized,
openSessionHelpModal: () => calls.push('help'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true,
@@ -72,6 +75,7 @@ test('build tray template handler wires actions and init guards', () => {
calls.push('jellyfin-discovery');
},
openAnilistSetupWindow: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
});
@@ -81,6 +85,7 @@ test('build tray template handler wires actions and init guards', () => {
'init',
'help',
'texthooker',
'show-texthooker:true',
'setup',
'setup',
'yomitan',
@@ -88,6 +93,7 @@ test('build tray template handler wires actions and init guards', () => {
'jellyfin',
'jellyfin-discovery',
'anilist',
'updates',
'quit',
]);
});
+8
View File
@@ -30,6 +30,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openSessionHelp: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: boolean;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
@@ -41,12 +42,14 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void;
openAnilistSetup: () => void;
checkForUpdates: () => void;
quitApp: () => void;
}) => TMenuItem[];
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
openSessionHelpModal: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: () => boolean;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
@@ -57,6 +60,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
isJellyfinDiscoveryActive: () => boolean;
toggleJellyfinDiscovery: () => void | Promise<void>;
openAnilistSetupWindow: () => void;
checkForUpdates: () => void;
quitApp: () => void;
}) {
return (): TMenuItem[] => {
@@ -70,6 +74,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openTexthookerInBrowser: () => {
deps.openTexthookerInBrowser();
},
showTexthookerPage: deps.showTexthookerPage(),
openFirstRunSetup: () => {
deps.openFirstRunSetupWindow();
},
@@ -98,6 +103,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openAnilistSetup: () => {
deps.openAnilistSetupWindow();
},
checkForUpdates: () => {
deps.checkForUpdates();
},
quitApp: () => {
deps.quitApp();
},
+4
View File
@@ -26,6 +26,7 @@ test('tray main deps builders return mapped handlers', () => {
isOverlayRuntimeInitialized: () => false,
openSessionHelpModal: () => calls.push('help'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true,
@@ -38,12 +39,14 @@ test('tray main deps builders return mapped handlers', () => {
calls.push('jellyfin-discovery');
},
openAnilistSetupWindow: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
})();
const template = menuDeps.buildTrayMenuTemplateRuntime({
openSessionHelp: () => calls.push('open-help'),
openTexthookerInBrowser: () => calls.push('open-texthooker'),
showTexthookerPage: true,
openFirstRunSetup: () => calls.push('open-setup'),
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
@@ -55,6 +58,7 @@ test('tray main deps builders return mapped handlers', () => {
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'),
openAnilistSetup: () => calls.push('open-anilist'),
checkForUpdates: () => calls.push('open-updates'),
quitApp: () => calls.push('quit-app'),
});
+6
View File
@@ -29,6 +29,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openSessionHelp: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: boolean;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
@@ -40,12 +41,14 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void;
openAnilistSetup: () => void;
checkForUpdates: () => void;
quitApp: () => void;
}) => TMenuItem[];
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
openSessionHelpModal: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: () => boolean;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
@@ -56,6 +59,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
isJellyfinDiscoveryActive: () => boolean;
toggleJellyfinDiscovery: () => void | Promise<void>;
openAnilistSetupWindow: () => void;
checkForUpdates: () => void;
quitApp: () => void;
}) {
return () => ({
@@ -64,6 +68,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
openSessionHelpModal: deps.openSessionHelpModal,
openTexthookerInBrowser: deps.openTexthookerInBrowser,
showTexthookerPage: deps.showTexthookerPage,
showFirstRunSetup: deps.showFirstRunSetup,
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
@@ -74,6 +79,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive,
toggleJellyfinDiscovery: deps.toggleJellyfinDiscovery,
openAnilistSetupWindow: deps.openAnilistSetupWindow,
checkForUpdates: deps.checkForUpdates,
quitApp: deps.quitApp,
});
}
@@ -26,6 +26,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
isOverlayRuntimeInitialized: () => overlayInitialized,
openSessionHelpModal: () => {},
openTexthookerInBrowser: () => {},
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => {},
showWindowsMpvLauncherSetup: () => true,
@@ -36,6 +37,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
isJellyfinDiscoveryActive: () => false,
toggleJellyfinDiscovery: () => {},
openAnilistSetupWindow: () => {},
checkForUpdates: () => {},
quitApp: () => {},
},
ensureTrayDeps: {
+44 -4
View File
@@ -31,6 +31,7 @@ test('tray menu template contains expected entries and handlers', () => {
const template = buildTrayMenuTemplateRuntime({
openSessionHelp: () => calls.push('help'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: true,
openFirstRunSetup: () => calls.push('setup'),
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
@@ -42,10 +43,11 @@ test('tray menu template contains expected entries and handlers', () => {
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'),
openAnilistSetup: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 11);
assert.equal(template.length, 12);
assert.equal(
template.some((entry) => entry.label === 'Open Overlay'),
false,
@@ -58,15 +60,25 @@ test('tray menu template contains expected entries and handlers', () => {
template[0]!.click?.();
assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.();
template[9]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[10]!.click?.();
assert.deepEqual(calls, ['jellyfin-discovery', 'help', 'texthooker', 'separator', 'quit']);
assert.equal(template[9]!.label, 'Check for Updates');
template[9]!.click?.();
template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[11]!.click?.();
assert.deepEqual(calls, [
'jellyfin-discovery',
'help',
'texthooker',
'updates',
'separator',
'quit',
]);
});
test('tray menu template omits first-run setup entry when setup is complete', () => {
const labels = buildTrayMenuTemplateRuntime({
openSessionHelp: () => undefined,
openTexthookerInBrowser: () => undefined,
showTexthookerPage: true,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
@@ -78,6 +90,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => undefined,
openAnilistSetup: () => undefined,
checkForUpdates: () => undefined,
quitApp: () => undefined,
})
.map((entry) => entry.label)
@@ -88,10 +101,36 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
assert.equal(labels.includes('Jellyfin Discovery'), false);
});
test('tray menu template omits texthooker entry when texthooker page is disabled', () => {
const labels = buildTrayMenuTemplateRuntime({
openSessionHelp: () => undefined,
openTexthookerInBrowser: () => undefined,
showTexthookerPage: false,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => undefined,
openAnilistSetup: () => undefined,
checkForUpdates: () => undefined,
quitApp: () => undefined,
})
.map((entry) => entry.label)
.filter(Boolean);
assert.equal(labels.includes('Open Texthooker'), false);
});
test('tray menu template renders active jellyfin discovery checkbox', () => {
const template = buildTrayMenuTemplateRuntime({
openSessionHelp: () => undefined,
openTexthookerInBrowser: () => undefined,
showTexthookerPage: true,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
@@ -103,6 +142,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
jellyfinDiscoveryActive: true,
toggleJellyfinDiscovery: () => undefined,
openAnilistSetup: () => undefined,
checkForUpdates: () => undefined,
quitApp: () => undefined,
});
+14 -4
View File
@@ -32,6 +32,7 @@ export function resolveTrayIconPathRuntime(deps: {
export type TrayMenuActionHandlers = {
openSessionHelp: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: boolean;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
@@ -43,6 +44,7 @@ export type TrayMenuActionHandlers = {
jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void;
openAnilistSetup: () => void;
checkForUpdates: () => void;
quitApp: () => void;
};
@@ -58,10 +60,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Open Help',
click: handlers.openSessionHelp,
},
{
label: 'Open Texthooker',
click: handlers.openTexthookerInBrowser,
},
...(handlers.showTexthookerPage
? [
{
label: 'Open Texthooker',
click: handlers.openTexthookerInBrowser,
},
]
: []),
...(handlers.showFirstRunSetup
? [
{
@@ -105,6 +111,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Configure AniList',
click: handlers.openAnilistSetup,
},
{
label: 'Check for Updates',
click: handlers.checkForUpdates,
},
{ type: 'separator' },
{
label: 'Quit',
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { configureAutoUpdater, type ElectronAutoUpdaterLike } from './app-updater';
type UpdaterLogger = {
info: (message: string) => void;
debug: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};
test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => {
const logged: string[] = [];
const updater: ElectronAutoUpdaterLike & { logger?: UpdaterLogger | null } = {
autoDownload: true,
allowPrerelease: true,
allowDowngrade: true,
logger: null,
checkForUpdates: async () => null,
downloadUpdate: async () => [],
quitAndInstall: () => {},
};
configureAutoUpdater(updater, (message) => logged.push(message));
assert.equal(updater.autoDownload, false);
assert.equal(updater.allowPrerelease, false);
assert.equal(updater.allowDowngrade, false);
assert.ok(updater.logger);
updater.logger.info('Checking for update');
updater.logger.debug('Generated new staging user ID');
updater.logger.warn('metadata missing');
updater.logger.error('download failed');
assert.deepEqual(logged, ['metadata missing', 'download failed']);
});
test('configureAutoUpdater allows prereleases only for the prerelease channel', () => {
const updater: ElectronAutoUpdaterLike = {
autoDownload: true,
allowPrerelease: false,
allowDowngrade: true,
logger: null,
checkForUpdates: async () => null,
downloadUpdate: async () => [],
quitAndInstall: () => {},
};
configureAutoUpdater(updater, () => {}, 'prerelease');
assert.equal(updater.allowPrerelease, true);
configureAutoUpdater(updater, () => {}, 'stable');
assert.equal(updater.allowPrerelease, false);
});
+92
View File
@@ -0,0 +1,92 @@
import { autoUpdater as electronAutoUpdater } from 'electron-updater';
import type { UpdateChannel } from '../../../types/config';
import { compareSemverLike } from './release-assets';
export interface AppUpdateCheckResult {
available: boolean;
version: string;
canUpdate: boolean;
}
export interface ElectronUpdaterLoggerLike {
info?: (message: string, ...args: unknown[]) => void;
debug?: (message: string, ...args: unknown[]) => void;
warn?: (message: string, ...args: unknown[]) => void;
error?: (message: string, ...args: unknown[]) => void;
}
export interface ElectronAutoUpdaterLike {
autoDownload: boolean;
allowPrerelease: boolean;
allowDowngrade: boolean;
logger?: ElectronUpdaterLoggerLike | null;
checkForUpdates: () => Promise<{
updateInfo?: {
version?: string;
};
} | null>;
downloadUpdate: () => Promise<unknown>;
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
}
export function configureAutoUpdater(
updater: ElectronAutoUpdaterLike,
log: (message: string) => void = () => {},
channel: UpdateChannel = 'stable',
): ElectronAutoUpdaterLike {
updater.autoDownload = false;
updater.allowPrerelease = channel === 'prerelease';
updater.allowDowngrade = false;
updater.logger = {
info: () => {},
debug: () => {},
warn: (message) => log(message),
error: (message) => log(message),
};
return updater;
}
export function createElectronAppUpdater(options: {
currentVersion: string;
isPackaged: boolean;
updater?: ElectronAutoUpdaterLike;
log: (message: string) => void;
getChannel?: () => UpdateChannel;
}) {
const getChannel = options.getChannel ?? (() => 'stable' as const);
const updater = configureAutoUpdater(
options.updater ?? electronAutoUpdater,
options.log,
getChannel(),
);
return {
async checkForUpdates(channel?: UpdateChannel): Promise<AppUpdateCheckResult> {
if (!options.isPackaged) {
return {
available: false,
version: options.currentVersion,
canUpdate: false,
};
}
configureAutoUpdater(updater, options.log, channel ?? getChannel());
const result = await updater.checkForUpdates();
const version = result?.updateInfo?.version ?? options.currentVersion;
return {
available: compareSemverLike(version, options.currentVersion) > 0,
version,
canUpdate: true,
};
},
async downloadUpdate(): Promise<void> {
if (!options.isPackaged) {
options.log('Skipping app update download because this build is not packaged.');
return;
}
await updater.downloadUpdate();
},
quitAndInstall(): void {
updater.quitAndInstall(false, true);
},
};
}
@@ -0,0 +1,126 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import {
buildProtectedLauncherUpdateCommand,
looksLikeSubminerLauncher,
updateLauncherAtPath,
} from './launcher-updater';
const launcherBytes = Buffer.from('#!/usr/bin/env bash\n# SubMiner launcher\nexec SubMiner "$@"\n');
const launcherHash = createHash('sha256').update(launcherBytes).digest('hex');
test('looksLikeSubminerLauncher rejects unrelated executable content', () => {
assert.equal(looksLikeSubminerLauncher(Buffer.from('#!/bin/sh\necho nope\n')), false);
assert.equal(looksLikeSubminerLauncher(Buffer.from('SubMiner launcher binary payload')), true);
});
test('buildProtectedLauncherUpdateCommand uses sudo curl and chmod for protected paths', () => {
assert.equal(
buildProtectedLauncherUpdateCommand(
'https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer',
'/usr/local/bin/subminer',
),
'sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer && sudo chmod +x /usr/local/bin/subminer',
);
});
test('updateLauncherAtPath verifies hash and atomically replaces writable launcher', async () => {
const writes: Array<{ path: string; data: Buffer }> = [];
const renames: Array<{ from: string; to: string }> = [];
const chmods: Array<{ path: string; mode: number }> = [];
const result = await updateLauncherAtPath({
launcherPath: '/home/kyle/.local/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: launcherHash,
download: async () => launcherBytes,
fs: {
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
stat: async () => ({ isFile: () => true, mode: 0o755 }),
access: async () => undefined,
writeFile: async (filePath, data) => {
writes.push({ path: filePath, data: Buffer.from(data) });
},
chmod: async (filePath, mode) => {
chmods.push({ path: filePath, mode });
},
rename: async (from, to) => {
renames.push({ from, to });
},
unlink: async () => undefined,
},
});
assert.equal(result.status, 'updated');
assert.equal(writes.length, 1);
assert.equal(writes[0]!.path, '/home/kyle/.local/bin/.subminer.update');
assert.equal(writes[0]!.data.equals(launcherBytes), true);
assert.deepEqual(chmods, [{ path: '/home/kyle/.local/bin/.subminer.update', mode: 0o755 }]);
assert.deepEqual(renames, [
{ from: '/home/kyle/.local/bin/.subminer.update', to: '/home/kyle/.local/bin/subminer' },
]);
});
test('updateLauncherAtPath reports protected command without replacing non-writable launcher', async () => {
const result = await updateLauncherAtPath({
launcherPath: '/usr/local/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: launcherHash,
download: async () => launcherBytes,
fs: {
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
stat: async () => ({ isFile: () => true, mode: 0o755 }),
access: async () => {
throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
},
writeFile: async () => {
throw new Error('unexpected write');
},
chmod: async () => undefined,
rename: async () => undefined,
unlink: async () => undefined,
},
});
assert.equal(result.status, 'protected');
assert.match(result.command ?? '', /^sudo curl -fSL https:\/\/example\.test\/subminer/);
});
test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher content', async () => {
const suspicious = await updateLauncherAtPath({
launcherPath: '/home/kyle/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: launcherHash,
download: async () => launcherBytes,
fs: {
readFile: async () => Buffer.from('#!/bin/sh\necho not-subminer\n'),
stat: async () => ({ isFile: () => true, mode: 0o755 }),
access: async () => undefined,
writeFile: async () => undefined,
chmod: async () => undefined,
rename: async () => undefined,
unlink: async () => undefined,
},
});
const mismatch = await updateLauncherAtPath({
launcherPath: '/home/kyle/.local/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: '0'.repeat(64),
download: async () => launcherBytes,
fs: {
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
stat: async () => ({ isFile: () => true, mode: 0o755 }),
access: async () => undefined,
writeFile: async () => {
throw new Error('unexpected write');
},
chmod: async () => undefined,
rename: async () => undefined,
unlink: async () => undefined,
},
});
assert.equal(suspicious.status, 'skipped');
assert.equal(mismatch.status, 'hash-mismatch');
});
+184
View File
@@ -0,0 +1,184 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { GitHubRelease } from './release-assets';
import { findReleaseAsset } from './release-assets';
type StatLike = {
isFile: () => boolean;
mode?: number;
};
export type LauncherUpdateStatus =
| 'updated'
| 'skipped'
| 'protected'
| 'hash-mismatch'
| 'not-found'
| 'missing-asset';
export interface LauncherUpdateResult {
status: LauncherUpdateStatus;
path?: string;
command?: string;
message?: string;
}
export interface LauncherUpdateFileSystem {
readFile: (targetPath: string) => Promise<Buffer | string>;
stat: (targetPath: string) => Promise<StatLike>;
access: (targetPath: string) => Promise<void>;
writeFile: (targetPath: string, data: Buffer) => Promise<void>;
chmod: (targetPath: string, mode: number) => Promise<void>;
rename: (fromPath: string, toPath: string) => Promise<void>;
unlink: (targetPath: string) => Promise<void>;
}
export function looksLikeSubminerLauncher(content: Buffer | string): boolean {
const text = Buffer.isBuffer(content) ? content.toString('utf8') : content;
return (
text.includes('SubMiner launcher') ||
text.includes('Launch MPV with SubMiner') ||
text.includes('SUBMINER_APPIMAGE_PATH') ||
text.includes('SubMiner.app') ||
text.includes('SubMiner.AppImage')
);
}
export function buildProtectedLauncherUpdateCommand(
assetUrl: string,
launcherPath: string,
): string {
return `sudo curl -fSL ${assetUrl} -o ${launcherPath} && sudo chmod +x ${launcherPath}`;
}
function sha256(data: Buffer): string {
return createHash('sha256').update(data).digest('hex');
}
function defaultFs(): LauncherUpdateFileSystem {
return {
readFile: (targetPath) => fs.promises.readFile(targetPath),
stat: (targetPath) => fs.promises.stat(targetPath),
access: async (targetPath) => {
await fs.promises.access(targetPath, fs.constants.W_OK);
},
writeFile: (targetPath, data) => fs.promises.writeFile(targetPath, data),
chmod: (targetPath, mode) => fs.promises.chmod(targetPath, mode),
rename: (fromPath, toPath) => fs.promises.rename(fromPath, toPath),
unlink: async (targetPath) => {
await fs.promises.unlink(targetPath).catch(() => undefined);
},
};
}
export async function updateLauncherAtPath(options: {
launcherPath: string;
assetUrl: string;
expectedSha256: string;
download: () => Promise<Buffer>;
fs?: LauncherUpdateFileSystem;
}): Promise<LauncherUpdateResult> {
const fsDeps = options.fs ?? defaultFs();
let stat: StatLike;
try {
stat = await fsDeps.stat(options.launcherPath);
} catch {
return { status: 'not-found', path: options.launcherPath };
}
if (!stat.isFile()) {
return { status: 'skipped', path: options.launcherPath, message: 'Launcher is not a file.' };
}
const existing = await fsDeps.readFile(options.launcherPath);
if (!looksLikeSubminerLauncher(existing)) {
return {
status: 'skipped',
path: options.launcherPath,
message: 'Existing executable does not look like a SubMiner launcher.',
};
}
try {
await fsDeps.access(options.launcherPath);
} catch {
return {
status: 'protected',
path: options.launcherPath,
command: buildProtectedLauncherUpdateCommand(options.assetUrl, options.launcherPath),
};
}
const data = await options.download();
const actualSha256 = sha256(data);
if (actualSha256 !== options.expectedSha256.toLowerCase()) {
return {
status: 'hash-mismatch',
path: options.launcherPath,
message: `Expected ${options.expectedSha256}, got ${actualSha256}.`,
};
}
const tempPath = path.join(path.dirname(options.launcherPath), '.subminer.update');
try {
await fsDeps.writeFile(tempPath, data);
await fsDeps.chmod(tempPath, stat.mode ? stat.mode & 0o777 : 0o755);
await fsDeps.rename(tempPath, options.launcherPath);
return { status: 'updated', path: options.launcherPath };
} catch (error) {
await fsDeps.unlink(tempPath);
throw error;
}
}
export function detectLauncherCandidates(options: {
platform: NodeJS.Platform;
homeDir: string;
explicitPath?: string;
}): string[] {
const candidates: string[] = [];
if (options.explicitPath) candidates.push(options.explicitPath);
if (options.platform === 'darwin') {
candidates.push('/usr/local/bin/subminer', '/opt/homebrew/bin/subminer');
candidates.push(path.join(options.homeDir, '.local/bin/subminer'));
} else if (options.platform === 'linux') {
candidates.push(path.join(options.homeDir, '.local/bin/subminer'));
candidates.push('/usr/local/bin/subminer', '/usr/bin/subminer');
}
return [...new Set(candidates)];
}
export async function updateLauncherFromRelease(options: {
release: GitHubRelease | null;
sha256Sums: Map<string, string>;
launcherPath?: string;
platform?: NodeJS.Platform;
homeDir?: string;
downloadAsset: (url: string) => Promise<Buffer>;
exists?: (targetPath: string) => boolean;
}): Promise<LauncherUpdateResult> {
if (!options.release) return { status: 'missing-asset', message: 'No release found.' };
const asset = findReleaseAsset(options.release, 'subminer');
if (!asset) return { status: 'missing-asset', message: 'Release has no subminer asset.' };
const expectedSha256 = options.sha256Sums.get('subminer');
if (!expectedSha256) {
return { status: 'missing-asset', message: 'SHA256SUMS.txt has no subminer entry.' };
}
const exists = options.exists ?? fs.existsSync;
const candidates = detectLauncherCandidates({
platform: options.platform ?? process.platform,
homeDir: options.homeDir ?? os.homedir(),
explicitPath: options.launcherPath,
});
const targetPath = candidates.find((candidate) => exists(candidate));
if (!targetPath) return { status: 'not-found', message: 'No installed launcher detected.' };
return await updateLauncherAtPath({
launcherPath: targetPath,
assetUrl: asset.browser_download_url,
expectedSha256,
download: () => options.downloadAsset(asset.browser_download_url),
});
}
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
compareSemverLike,
findReleaseAsset,
parseSha256Sums,
selectLatestStableRelease,
} from './release-assets';
test('parseSha256Sums maps release asset basenames to hashes', () => {
const sums = parseSha256Sums(`
1111111111111111111111111111111111111111111111111111111111111111 SubMiner.AppImage
2222222222222222222222222222222222222222222222222222222222222222 *subminer
`);
assert.equal(
sums.get('SubMiner.AppImage'),
'1111111111111111111111111111111111111111111111111111111111111111',
);
assert.equal(
sums.get('subminer'),
'2222222222222222222222222222222222222222222222222222222222222222',
);
});
test('selectLatestStableRelease ignores drafts and prereleases', () => {
const release = selectLatestStableRelease([
{ tag_name: 'v0.16.0-beta.1', draft: false, prerelease: true, assets: [] },
{ tag_name: 'v0.15.0', draft: true, prerelease: false, assets: [] },
{ tag_name: 'v0.14.1', draft: false, prerelease: false, assets: [] },
]);
assert.equal(release?.tag_name, 'v0.14.1');
});
test('selectLatestStableRelease can opt into prerelease releases', () => {
const release = selectLatestStableRelease(
[
{ tag_name: 'v0.16.0-beta.1', draft: false, prerelease: true, assets: [] },
{ tag_name: 'v0.15.0', draft: false, prerelease: false, assets: [] },
],
'prerelease',
);
assert.equal(release?.tag_name, 'v0.16.0-beta.1');
});
test('compareSemverLike orders prerelease identifiers within the same base version', () => {
assert.equal(compareSemverLike('0.15.0-beta.2', '0.15.0-beta.1') > 0, true);
assert.equal(compareSemverLike('0.15.0-rc.1', '0.15.0-beta.2') > 0, true);
assert.equal(compareSemverLike('0.15.0', '0.15.0-rc.1') > 0, true);
});
test('findReleaseAsset finds exact asset names only', () => {
const release = {
tag_name: 'v0.14.1',
draft: false,
prerelease: false,
assets: [
{ name: 'subminer', browser_download_url: 'https://example.test/subminer' },
{ name: 'subminer-assets.tar.gz', browser_download_url: 'https://example.test/assets' },
],
};
assert.equal(
findReleaseAsset(release, 'subminer')?.browser_download_url,
'https://example.test/subminer',
);
assert.equal(findReleaseAsset(release, 'latest.yml'), null);
});
+186
View File
@@ -0,0 +1,186 @@
import type { UpdateChannel } from '../../../types/config';
export interface GitHubReleaseAsset {
name: string;
browser_download_url: string;
size?: number;
}
export interface GitHubRelease {
tag_name: string;
name?: string;
draft?: boolean;
prerelease?: boolean;
html_url?: string;
assets: GitHubReleaseAsset[];
}
export interface FetchResponseLike {
ok: boolean;
status: number;
statusText?: string;
json: () => Promise<unknown>;
text: () => Promise<string>;
arrayBuffer: () => Promise<ArrayBuffer>;
}
export type FetchLike = (url: string, init?: Record<string, unknown>) => Promise<FetchResponseLike>;
export function parseSha256Sums(text: string): Map<string, string> {
const sums = new Map<string, string>();
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
if (!match) continue;
const [, hash, name] = match;
if (!hash || !name) continue;
sums.set(name.trim().split(/[\\/]/).pop() ?? name.trim(), hash.toLowerCase());
}
return sums;
}
export function selectLatestStableRelease(
releases: GitHubRelease[],
channel: UpdateChannel = 'stable',
): GitHubRelease | null {
return (
releases.find(
(release) => !release.draft && (channel === 'prerelease' || !release.prerelease),
) ?? null
);
}
export function findReleaseAsset(
release: Pick<GitHubRelease, 'assets'>,
assetName: string,
): GitHubReleaseAsset | null {
return release.assets.find((asset) => asset.name === assetName) ?? null;
}
function assertRelease(value: unknown): GitHubRelease | null {
if (!value || typeof value !== 'object') return null;
const release = value as Partial<GitHubRelease>;
if (typeof release.tag_name !== 'string' || !Array.isArray(release.assets)) return null;
return {
tag_name: release.tag_name,
name: typeof release.name === 'string' ? release.name : undefined,
draft: release.draft === true,
prerelease: release.prerelease === true,
html_url: typeof release.html_url === 'string' ? release.html_url : undefined,
assets: release.assets
.filter((asset): asset is GitHubReleaseAsset => {
const candidate = asset as Partial<GitHubReleaseAsset>;
return (
typeof candidate.name === 'string' && typeof candidate.browser_download_url === 'string'
);
})
.map((asset) => ({
name: asset.name,
browser_download_url: asset.browser_download_url,
size: typeof asset.size === 'number' ? asset.size : undefined,
})),
};
}
export async function fetchLatestStableRelease(options: {
fetch: FetchLike;
owner?: string;
repo?: string;
channel?: UpdateChannel;
}): Promise<GitHubRelease | null> {
const owner = options.owner ?? 'ksyasuda';
const repo = options.repo ?? 'SubMiner';
const response = await options.fetch(`https://api.github.com/repos/${owner}/${repo}/releases`, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'SubMiner updater',
},
});
if (!response.ok) {
throw new Error(`GitHub releases request failed with ${response.status}`);
}
const body = await response.json();
if (!Array.isArray(body)) return null;
return selectLatestStableRelease(
body.map(assertRelease).filter((item): item is GitHubRelease => item !== null),
options.channel,
);
}
export async function fetchReleaseAssetText(fetch: FetchLike, assetUrl: string): Promise<string> {
const response = await fetch(assetUrl);
if (!response.ok) {
throw new Error(`Release asset request failed with ${response.status}`);
}
return await response.text();
}
export async function fetchReleaseAssetBuffer(fetch: FetchLike, assetUrl: string): Promise<Buffer> {
const response = await fetch(assetUrl);
if (!response.ok) {
throw new Error(`Release asset request failed with ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
export function parseReleaseVersion(
release: Pick<GitHubRelease, 'tag_name'> | null,
): string | null {
if (!release) return null;
return release.tag_name.replace(/^v/i, '');
}
export function compareSemverLike(a: string, b: string): number {
const parse = (
value: string,
): {
core: number[];
prerelease: Array<number | string>;
} => {
const normalized = value.replace(/^v/i, '');
const [coreText = '', ...prereleaseParts] = normalized.split('-');
const core = coreText
.split('.')
.slice(0, 3)
.map((part) => Number.parseInt(part, 10) || 0);
while (core.length < 3) core.push(0);
const prereleaseText = prereleaseParts.join('-');
return {
core,
prerelease: prereleaseText
? prereleaseText.split('.').map((part) => {
const numeric = Number.parseInt(part, 10);
return /^\d+$/.test(part) ? numeric : part;
})
: [],
};
};
const left = parse(a);
const right = parse(b);
for (let i = 0; i < 3; i += 1) {
const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0);
if (diff !== 0) return diff;
}
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
if (left.prerelease.length === 0) return 1;
if (right.prerelease.length === 0) return -1;
const length = Math.max(left.prerelease.length, right.prerelease.length);
for (let i = 0; i < length; i += 1) {
const leftPart = left.prerelease[i];
const rightPart = right.prerelease[i];
if (leftPart === undefined && rightPart === undefined) return 0;
if (leftPart === undefined) return -1;
if (rightPart === undefined) return 1;
if (leftPart === rightPart) continue;
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
return leftPart - rightPart;
}
if (typeof leftPart === 'number') return -1;
if (typeof rightPart === 'number') return 1;
return leftPart > rightPart ? 1 : -1;
}
return 0;
}
+164
View File
@@ -0,0 +1,164 @@
import { createHash } from 'node:crypto';
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import type { GitHubRelease } from './release-assets';
import { findReleaseAsset } from './release-assets';
const execFileAsync = promisify(execFile);
export interface SupportAssetsUpdateResult {
status: 'updated' | 'skipped' | 'protected' | 'hash-mismatch' | 'missing-asset';
path?: string;
command?: string;
message?: string;
}
function sha256(data: Buffer): string {
return createHash('sha256').update(data).digest('hex');
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function detectSupportAssetDataDirs(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgDataHome?: string;
}): string[] {
if (options.platform === 'darwin') {
return [
path.join(options.homeDir, 'Library/Application Support/SubMiner'),
'/usr/local/share/SubMiner',
];
}
if (options.platform === 'linux') {
const xdgDataHome = options.xdgDataHome || path.join(options.homeDir, '.local/share');
return [path.join(xdgDataHome, 'SubMiner'), '/usr/local/share/SubMiner', '/usr/share/SubMiner'];
}
return [];
}
export function buildProtectedSupportAssetsCommand(assetUrl: string, dataDir: string): string {
const quotedDir = shellQuote(dataDir);
return [
'tmp=$(mktemp -d)',
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
`sudo mkdir -p ${quotedDir}/plugin/subminer ${quotedDir}/themes`,
`sudo cp -R "$tmp/plugin/subminer/." ${quotedDir}/plugin/subminer/`,
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
].join(' && ');
}
async function pathExists(targetPath: string): Promise<boolean> {
return await fs.promises
.access(targetPath)
.then(() => true)
.catch(() => false);
}
async function canWrite(targetPath: string): Promise<boolean> {
return await fs.promises
.access(targetPath, fs.constants.W_OK)
.then(() => true)
.catch(() => false);
}
export async function updateSupportAssetsFromRelease(options: {
release: GitHubRelease | null;
sha256Sums: Map<string, string>;
downloadAsset: (url: string) => Promise<Buffer>;
platform?: NodeJS.Platform;
homeDir?: string;
xdgDataHome?: string;
}): Promise<SupportAssetsUpdateResult[]> {
if (!options.release) return [{ status: 'missing-asset', message: 'No release found.' }];
const asset = findReleaseAsset(options.release, 'subminer-assets.tar.gz');
if (!asset) return [{ status: 'missing-asset', message: 'Release has no support assets.' }];
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
if (!expectedSha256) {
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no support assets entry.' }];
}
const dataDirs = detectSupportAssetDataDirs({
platform: options.platform ?? process.platform,
homeDir: options.homeDir ?? os.homedir(),
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
});
const existingDataDirs: string[] = [];
for (const dataDir of dataDirs) {
const hasPlugin = await pathExists(path.join(dataDir, 'plugin/subminer'));
const hasTheme = await pathExists(path.join(dataDir, 'themes/subminer.rasi'));
if (hasPlugin || hasTheme) existingDataDirs.push(dataDir);
}
if (existingDataDirs.length === 0) {
return [{ status: 'skipped', message: 'No existing support asset install detected.' }];
}
const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs
.filter((dataDir) => !fs.existsSync(dataDir) || !fs.statSync(dataDir).isDirectory())
.map((dataDir) => ({
status: 'skipped' as const,
path: dataDir,
message: 'Support asset path is not a directory.',
}));
const writableDataDirs: string[] = [];
for (const dataDir of existingDataDirs) {
if (await canWrite(dataDir)) {
writableDataDirs.push(dataDir);
} else {
protectedResults.push({
status: 'protected',
path: dataDir,
command: buildProtectedSupportAssetsCommand(asset.browser_download_url, dataDir),
});
}
}
if (writableDataDirs.length === 0) return protectedResults;
const archive = await options.downloadAsset(asset.browser_download_url);
const actualSha256 = sha256(archive);
if (actualSha256 !== expectedSha256.toLowerCase()) {
return [
...protectedResults,
{
status: 'hash-mismatch',
message: `Expected ${expectedSha256}, got ${actualSha256}.`,
},
];
}
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-assets-'));
try {
const archivePath = path.join(tempDir, 'subminer-assets.tar.gz');
await fs.promises.writeFile(archivePath, archive);
await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir]);
const results: SupportAssetsUpdateResult[] = [...protectedResults];
for (const dataDir of writableDataDirs) {
const targetPluginDir = path.join(dataDir, 'plugin/subminer');
const targetThemePath = path.join(dataDir, 'themes/subminer.rasi');
if (await pathExists(targetPluginDir)) {
await fs.promises.mkdir(targetPluginDir, { recursive: true });
await fs.promises.cp(path.join(tempDir, 'plugin/subminer'), targetPluginDir, {
recursive: true,
force: true,
});
}
if (await pathExists(targetThemePath)) {
await fs.promises.mkdir(path.dirname(targetThemePath), { recursive: true });
await fs.promises.copyFile(
path.join(tempDir, 'assets/themes/subminer.rasi'),
targetThemePath,
);
}
results.push({ status: 'updated', path: dataDir });
}
return results;
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { CliArgs } from '../../../cli/args';
import { runUpdateCliCommand } from './update-cli-command';
test('runUpdateCliCommand writes launcher response for second-instance update handoff', async () => {
const writes: Array<{ path: string; payload: unknown }> = [];
await runUpdateCliCommand(
{
update: true,
updateLauncherPath: '/home/kyle/.local/bin/subminer',
updateResponsePath: '/tmp/subminer-update-response.json',
} as CliArgs,
'second-instance',
{
checkForUpdates: async (request) => {
assert.deepEqual(request, {
source: 'launcher',
launcherPath: '/home/kyle/.local/bin/subminer',
});
return { status: 'up-to-date', version: '0.15.0' };
},
writeResponse: (responsePath, payload) => {
writes.push({ path: responsePath, payload });
},
logWarn: () => {},
},
);
assert.deepEqual(writes, [
{
path: '/tmp/subminer-update-response.json',
payload: { ok: true, status: 'up-to-date', version: '0.15.0' },
},
]);
});
@@ -0,0 +1,71 @@
import fs from 'node:fs';
import path from 'node:path';
import type { CliArgs, CliCommandSource } from '../../../cli/args';
import type { UpdateCheckRequest, UpdateCheckResult } from './update-service';
export interface UpdateCliCommandResponse {
ok: boolean;
status?: UpdateCheckResult['status'];
version?: string;
error?: string;
}
export interface UpdateCliCommandDeps {
checkForUpdates: (request: UpdateCheckRequest) => Promise<UpdateCheckResult>;
writeResponse: (responsePath: string, payload: UpdateCliCommandResponse) => void;
logWarn: (message: string, error?: unknown) => void;
}
export function writeUpdateCliCommandResponse(
responsePath: string,
payload: UpdateCliCommandResponse,
): void {
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
fs.writeFileSync(responsePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
function responseFromResult(result: UpdateCheckResult): UpdateCliCommandResponse {
const response: UpdateCliCommandResponse = {
ok: result.status !== 'failed',
status: result.status,
};
if (result.version !== undefined) response.version = result.version;
if (result.error !== undefined) response.error = result.error;
return response;
}
function writeResponseSafe(
responsePath: string | undefined,
payload: UpdateCliCommandResponse,
deps: Pick<UpdateCliCommandDeps, 'writeResponse' | 'logWarn'>,
): void {
if (!responsePath) return;
try {
deps.writeResponse(responsePath, payload);
} catch (error) {
deps.logWarn(`Failed to write update response: ${responsePath}`, error);
}
}
export async function runUpdateCliCommand(
args: Pick<CliArgs, 'updateLauncherPath' | 'updateResponsePath'>,
_source: CliCommandSource,
deps: UpdateCliCommandDeps,
): Promise<UpdateCheckResult> {
try {
const result = await deps.checkForUpdates({
source: args.updateLauncherPath ? 'launcher' : 'manual',
launcherPath: args.updateLauncherPath,
});
writeResponseSafe(args.updateResponsePath, responseFromResult(result), deps);
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
writeResponseSafe(
args.updateResponsePath,
{ ok: false, status: 'failed', error: message },
deps,
);
throw error;
}
}
+68
View File
@@ -0,0 +1,68 @@
export type UpdateAvailableChoice = 'update' | 'close';
export type RestartChoice = 'restart' | 'later';
export interface MessageBoxResultLike {
response: number;
}
export type ShowMessageBox = (options: {
type?: 'info' | 'warning' | 'error' | 'question';
title?: string;
message: string;
detail?: string;
buttons?: string[];
defaultId?: number;
cancelId?: number;
}) => Promise<MessageBoxResultLike>;
export async function showNoUpdateDialog(
showMessageBox: ShowMessageBox,
version: string,
): Promise<void> {
await showMessageBox({
type: 'info',
title: 'SubMiner Updates',
message: `SubMiner is up to date (v${version})`,
buttons: ['Close'],
});
}
export async function showUpdateAvailableDialog(
showMessageBox: ShowMessageBox,
version: string,
): Promise<UpdateAvailableChoice> {
const result = await showMessageBox({
type: 'question',
title: 'SubMiner Updates',
message: `SubMiner v${version} is available`,
buttons: ['Update', 'Close'],
defaultId: 0,
cancelId: 1,
});
return result.response === 0 ? 'update' : 'close';
}
export async function showRestartDialog(showMessageBox: ShowMessageBox): Promise<RestartChoice> {
const result = await showMessageBox({
type: 'question',
title: 'SubMiner Updates',
message: 'Restart to update',
buttons: ['Restart', 'Later'],
defaultId: 0,
cancelId: 1,
});
return result.response === 0 ? 'restart' : 'later';
}
export async function showUpdateFailedDialog(
showMessageBox: ShowMessageBox,
message: string,
): Promise<void> {
await showMessageBox({
type: 'error',
title: 'SubMiner Updates',
message: 'Update check failed',
detail: message,
buttons: ['Close'],
});
}
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { notifyUpdateAvailable } from './update-notifications';
test('notifyUpdateAvailable routes system and osd notifications from config', async () => {
const calls: string[] = [];
const deps = {
showSystemNotification: (title: string, body: string) => {
calls.push(`system:${title}:${body}`);
},
showOsdNotification: async (message: string) => {
calls.push(`osd:${message}`);
},
log: (message: string) => {
calls.push(`log:${message}`);
},
};
await notifyUpdateAvailable({ notificationType: 'system', version: '0.15.0' }, deps);
await notifyUpdateAvailable({ notificationType: 'both', version: '0.15.0' }, deps);
await notifyUpdateAvailable({ notificationType: 'none', version: '0.15.0' }, deps);
assert.deepEqual(calls, [
'system:SubMiner update available:SubMiner v0.15.0 is available',
'system:SubMiner update available:SubMiner v0.15.0 is available',
'osd:SubMiner v0.15.0 is available',
]);
});
test('notifyUpdateAvailable logs osd fallback when overlay notification fails', async () => {
const calls: string[] = [];
await notifyUpdateAvailable(
{ notificationType: 'osd', version: '0.15.0' },
{
showSystemNotification: () => {
calls.push('system');
},
showOsdNotification: async () => {
throw new Error('mpv disconnected');
},
log: (message) => {
calls.push(message);
},
},
);
assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']);
});
@@ -0,0 +1,26 @@
import type { UpdateNotificationType } from '../../../types/config';
export interface UpdateNotificationDeps {
showSystemNotification: (title: string, body: string) => void;
showOsdNotification: (message: string) => void | Promise<void>;
log: (message: string) => void;
}
export async function notifyUpdateAvailable(
options: { notificationType: UpdateNotificationType; version: string },
deps: UpdateNotificationDeps,
): Promise<void> {
if (options.notificationType === 'none') return;
const message = `SubMiner v${options.version} is available`;
if (options.notificationType === 'system' || options.notificationType === 'both') {
deps.showSystemNotification('SubMiner update available', message);
}
if (options.notificationType === 'osd' || options.notificationType === 'both') {
try {
await deps.showOsdNotification(message);
} catch (error) {
deps.log(`Update OSD notification failed: ${(error as Error).message}`);
}
}
}
@@ -0,0 +1,183 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createUpdateService, type UpdateServiceDeps, type UpdateState } from './update-service';
function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
let state: UpdateState = {};
const calls: string[] = [];
const deps: UpdateServiceDeps = {
getConfig: () => ({
enabled: true,
checkIntervalHours: 24,
notificationType: 'system',
channel: 'stable',
}),
getCurrentVersion: () => '0.14.0',
now: () => 1_000_000,
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',
prerelease: false,
draft: false,
assets: [],
}),
updateLauncher: async () => ({ status: 'skipped' }),
showNoUpdateDialog: async (version) => {
calls.push(`no-update:${version}`);
},
showUpdateAvailableDialog: async (version) => {
calls.push(`available-dialog:${version}`);
return 'close';
},
showUpdateFailedDialog: async (message) => {
calls.push(`failed:${message}`);
},
downloadAppUpdate: async () => {
calls.push('download');
},
showRestartDialog: async () => {
calls.push('restart-dialog');
return 'later';
},
quitAndInstall: () => calls.push('quit-install'),
notifyUpdateAvailable: async (version) => {
calls.push(`notify:${version}`);
},
log: (message) => calls.push(`log:${message}`),
...overrides,
};
return {
deps,
calls,
getState: () => state,
setState: (nextState: UpdateState) => (state = nextState),
};
}
test('manual update check shows latest-version dialog when already current', async () => {
const { deps, calls } = createDeps();
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'up-to-date');
assert.deepEqual(calls, ['no-update:0.14.0']);
});
test('manual update check falls back to GitHub release when app metadata is unavailable', async () => {
const { deps, calls } = createDeps({
checkAppUpdate: async () => {
throw new Error('latest-linux.yml missing');
},
fetchLatestStableRelease: async () => ({
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [],
}),
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'update-available');
assert.deepEqual(calls, ['available-dialog:0.15.0']);
});
test('automatic update check skips inside configured interval', async () => {
const { deps, calls, setState } = createDeps();
setState({ lastAutomaticCheckAt: 1_000_000 - 60 * 60 * 1000 });
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'automatic' });
assert.equal(result.status, 'skipped');
assert.deepEqual(calls, []);
});
test('automatic update check notifies once per version and records check time', async () => {
const { deps, calls, getState } = createDeps({
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
});
const service = createUpdateService(deps);
const first = await service.checkForUpdates({ source: 'automatic' });
const second = await service.checkForUpdates({ source: 'automatic', force: true });
assert.equal(first.status, 'update-available');
assert.equal(second.status, 'update-available');
assert.deepEqual(
calls.filter((call) => call === 'notify:0.15.0'),
['notify:0.15.0'],
);
assert.equal(getState().lastNotifiedVersion, '0.15.0');
assert.equal(getState().lastAutomaticCheckAt, 1_000_000);
});
test('concurrent update checks share one in-flight check', async () => {
let checkCount = 0;
let resolveCheck: (value: { available: boolean; version: string }) => void = () => {};
const { deps } = createDeps({
checkAppUpdate: () =>
new Promise((resolve) => {
checkCount += 1;
resolveCheck = resolve;
}),
});
const service = createUpdateService(deps);
const first = service.checkForUpdates({ source: 'manual' });
const second = service.checkForUpdates({ source: 'manual' });
await Promise.resolve();
resolveCheck({ available: false, version: '0.14.0' });
await Promise.all([first, second]);
assert.equal(checkCount, 1);
});
test('manual prerelease update check uses prerelease release and launcher channel', async () => {
const { deps, calls } = createDeps({
getConfig: () => ({
enabled: true,
checkIntervalHours: 24,
notificationType: 'system',
channel: 'prerelease',
}),
checkAppUpdate: async () => ({ available: true, version: '0.15.0-beta.1' }),
fetchLatestStableRelease: async (channel) => {
calls.push(`fetch:${channel}`);
return {
tag_name: 'v0.15.0-beta.1',
prerelease: true,
draft: false,
assets: [],
};
},
showUpdateAvailableDialog: async (version) => {
calls.push(`available-dialog:${version}`);
return 'update';
},
updateLauncher: async (_launcherPath, channel) => {
calls.push(`launcher:${channel}`);
return { status: 'skipped' };
},
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'updated');
assert.deepEqual(calls, [
'fetch:prerelease',
'available-dialog:0.15.0-beta.1',
'download',
'launcher:prerelease',
'restart-dialog',
]);
});
+224
View File
@@ -0,0 +1,224 @@
import fs from 'node:fs';
import path from 'node:path';
import type { UpdateChannel, UpdatesConfig } from '../../../types/config';
import type { GitHubRelease } from './release-assets';
import { compareSemverLike, parseReleaseVersion } from './release-assets';
export interface UpdateState {
lastAutomaticCheckAt?: number;
lastNotifiedVersion?: string;
}
export type UpdateCheckSource = 'manual' | 'automatic' | 'launcher';
export interface UpdateCheckRequest {
source: UpdateCheckSource;
force?: boolean;
launcherPath?: string;
}
export type UpdateCheckStatus =
| 'up-to-date'
| 'update-available'
| 'updated'
| 'skipped'
| 'failed';
export interface UpdateCheckResult {
status: UpdateCheckStatus;
version?: string;
error?: string;
}
export interface UpdateServiceDeps {
getConfig: () => Required<UpdatesConfig>;
getCurrentVersion: () => string;
now: () => number;
readState: () => Promise<UpdateState>;
writeState: (state: UpdateState) => Promise<void>;
checkAppUpdate: (
channel: UpdateChannel,
) => Promise<{ available: boolean; version: string; canUpdate?: boolean }>;
fetchLatestStableRelease: (channel: UpdateChannel) => Promise<GitHubRelease | null>;
updateLauncher: (
launcherPath?: string,
channel?: UpdateChannel,
) => Promise<{ status: string; command?: string }>;
showNoUpdateDialog: (version: string) => Promise<void>;
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
showUpdateFailedDialog: (message: string) => Promise<void>;
downloadAppUpdate: () => Promise<void>;
showRestartDialog: () => Promise<'restart' | 'later'>;
quitAndInstall: () => void;
notifyUpdateAvailable: (version: string) => Promise<void>;
log: (message: string) => void;
setTimeout?: (callback: () => void, delayMs: number) => unknown;
setInterval?: (callback: () => void, delayMs: number) => unknown;
}
function getBestLatestVersion(
currentVersion: string,
appUpdate: { available: boolean; version: string },
release: GitHubRelease | null,
): { available: boolean; version: string } {
const releaseVersion = parseReleaseVersion(release);
const candidates = [appUpdate.version, releaseVersion].filter(
(value): value is string => typeof value === 'string' && value.length > 0,
);
const latest = candidates.reduce(
(best, candidate) => (compareSemverLike(candidate, best) > 0 ? candidate : best),
currentVersion,
);
return {
available: appUpdate.available || compareSemverLike(latest, currentVersion) > 0,
version: latest,
};
}
function shouldSkipAutomaticCheck(
config: Required<UpdatesConfig>,
state: UpdateState,
now: number,
) {
if (!config.enabled) return true;
if (!state.lastAutomaticCheckAt) return false;
const intervalMs = Math.max(1, config.checkIntervalHours) * 60 * 60 * 1000;
return now - state.lastAutomaticCheckAt < intervalMs;
}
function summarizeError(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error);
const firstLine = raw
.split('\n')
.map((line) => line.trim())
.find((line) => line.length > 0);
return firstLine ?? 'unknown error';
}
export function createUpdateService(deps: UpdateServiceDeps) {
let inFlight: Promise<UpdateCheckResult> | null = null;
async function runCheck(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
const now = deps.now();
const config = deps.getConfig();
const channel = config.channel;
const state = await deps.readState();
const isAutomatic = request.source === 'automatic';
if (isAutomatic && !request.force && shouldSkipAutomaticCheck(config, state, now)) {
return { status: 'skipped' };
}
try {
const [appUpdate, release] = await Promise.all([
deps.checkAppUpdate(channel).catch((error) => {
if (isAutomatic) {
deps.log(`App update metadata check failed: ${summarizeError(error)}`);
}
return {
available: false,
version: deps.getCurrentVersion(),
canUpdate: false,
};
}),
deps.fetchLatestStableRelease(channel).catch((error) => {
deps.log(`GitHub release update check failed: ${(error as Error).message}`);
return null;
}),
]);
const currentVersion = deps.getCurrentVersion();
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);
}
if (!latest.available) {
if (!isAutomatic) {
await deps.showNoUpdateDialog(currentVersion);
}
return { status: 'up-to-date', version: currentVersion };
}
if (isAutomatic) {
return { status: 'update-available', version: latest.version };
}
const choice = await deps.showUpdateAvailableDialog(latest.version);
if (choice === 'close') {
return { status: 'update-available', version: latest.version };
}
if (appUpdate.available && appUpdate.canUpdate !== false) {
await deps.downloadAppUpdate();
}
const launcherResult = await deps.updateLauncher(request.launcherPath, channel);
if (launcherResult.status === 'protected' && launcherResult.command) {
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
}
const restartChoice = await deps.showRestartDialog();
if (restartChoice === 'restart') {
deps.quitAndInstall();
}
return { status: 'updated', version: latest.version };
} catch (error) {
const message = (error as Error).message;
if (isAutomatic) {
deps.log(`Automatic update check failed: ${message}`);
} else {
await deps.showUpdateFailedDialog(message);
}
return { status: 'failed', error: message };
}
}
return {
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
if (inFlight) return inFlight;
inFlight = runCheck(request).finally(() => {
inFlight = null;
});
return inFlight;
},
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
const setTimeoutFn = deps.setTimeout ?? setTimeout;
const setIntervalFn = deps.setInterval ?? setInterval;
const startupDelayMs = options.startupDelayMs ?? 15_000;
const pollIntervalMs = options.pollIntervalMs ?? 60 * 60 * 1000;
setTimeoutFn(() => {
void this.checkForUpdates({ source: 'automatic' });
}, startupDelayMs);
setIntervalFn(() => {
void this.checkForUpdates({ source: 'automatic' });
}, pollIntervalMs);
},
};
}
export function createFileUpdateStateStore(statePath: string): {
readState: () => Promise<UpdateState>;
writeState: (state: UpdateState) => Promise<void>;
} {
return {
async readState(): Promise<UpdateState> {
try {
return JSON.parse(await fs.promises.readFile(statePath, 'utf8')) as UpdateState;
} catch {
return {};
}
},
async writeState(state: UpdateState): Promise<void> {
await fs.promises.mkdir(path.dirname(statePath), { recursive: true });
await fs.promises.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
},
};
}
@@ -41,3 +41,107 @@ test('yomitan opener opens settings window when extension is available', async (
await Promise.resolve();
assert.equal(forwardedSession, yomitanSession);
});
test('yomitan opener opens settings window without a dedicated session', async () => {
let forwardedSession: unknown = 'unset';
const logs: string[] = [];
const openSettings = createOpenYomitanSettingsHandler({
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
openYomitanSettingsWindow: ({ yomitanSession: nextSession }) => {
forwardedSession = nextSession;
},
getExistingWindow: () => null,
setWindow: () => {},
getYomitanSession: () => null,
logWarn: (message) => logs.push(message),
logError: () => logs.push('error'),
});
openSettings();
await Promise.resolve();
await Promise.resolve();
assert.equal(forwardedSession, null);
assert.deepEqual(logs, []);
});
test('yomitan opener does not start settings-triggered extension load while startup load is in flight', async () => {
let ensureCalled = false;
const logs: string[] = [];
const startupLoad = new Promise<unknown>(() => {});
const openSettings = createOpenYomitanSettingsHandler({
ensureYomitanExtensionLoaded: async () => {
ensureCalled = true;
return { id: 'ext' };
},
getYomitanExtension: () => null,
getYomitanExtensionLoadInFlight: () => startupLoad,
openYomitanSettingsWindow: () => {
throw new Error('should not open while startup load is in flight');
},
getExistingWindow: () => null,
setWindow: () => {},
logWarn: (message) => logs.push(message),
logError: () => logs.push('error'),
});
openSettings();
await Promise.resolve();
await Promise.resolve();
assert.equal(ensureCalled, false);
assert.deepEqual(logs, [
'Yomitan settings requested while Yomitan is still loading. Try again in a few seconds.',
]);
});
test('yomitan opener uses loaded extension from app state without calling loader', async () => {
let forwardedExtension: { id: string } | null = null;
const appStateExtension = { id: 'loaded-ext' };
const openSettings = createOpenYomitanSettingsHandler({
ensureYomitanExtensionLoaded: async () => {
throw new Error('should not load extension from settings click');
},
getYomitanExtension: () => appStateExtension,
getYomitanExtensionLoadInFlight: () => null,
openYomitanSettingsWindow: ({ yomitanExt }) => {
forwardedExtension = yomitanExt as { id: string };
},
getExistingWindow: () => null,
setWindow: () => {},
logWarn: () => {},
logError: () => {},
});
openSettings();
await Promise.resolve();
assert.equal(forwardedExtension, appStateExtension);
});
test('yomitan opener warns instead of starting a settings-triggered load when extension is not ready', async () => {
let ensureCalled = false;
const logs: string[] = [];
const openSettings = createOpenYomitanSettingsHandler({
ensureYomitanExtensionLoaded: async () => {
ensureCalled = true;
return { id: 'ext' };
},
getYomitanExtension: () => null,
getYomitanExtensionLoadInFlight: () => null,
openYomitanSettingsWindow: () => {
throw new Error('should not open before extension is ready');
},
getExistingWindow: () => null,
setWindow: () => {},
logWarn: (message) => logs.push(message),
logError: () => logs.push('error'),
});
openSettings();
await Promise.resolve();
await Promise.resolve();
assert.equal(ensureCalled, false);
assert.deepEqual(logs, ['Unable to open Yomitan settings: extension is not loaded yet.']);
});
+25 -4
View File
@@ -4,6 +4,8 @@ type SessionLike = unknown;
export function createOpenYomitanSettingsHandler(deps: {
ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>;
getYomitanExtension?: () => YomitanExtensionLike | null;
getYomitanExtensionLoadInFlight?: () => Promise<unknown> | null;
openYomitanSettingsWindow: (params: {
yomitanExt: YomitanExtensionLike;
getExistingWindow: () => BrowserWindowLike | null;
@@ -19,16 +21,35 @@ export function createOpenYomitanSettingsHandler(deps: {
}) {
return (): void => {
void (async () => {
if (deps.getYomitanExtension) {
const loadedExtension = deps.getYomitanExtension();
if (!loadedExtension) {
if (deps.getYomitanExtensionLoadInFlight?.()) {
deps.logWarn(
'Yomitan settings requested while Yomitan is still loading. Try again in a few seconds.',
);
return;
}
deps.logWarn('Unable to open Yomitan settings: extension is not loaded yet.');
return;
}
const yomitanSession = deps.getYomitanSession?.() ?? null;
deps.openYomitanSettingsWindow({
yomitanExt: loadedExtension,
getExistingWindow: deps.getExistingWindow,
setWindow: deps.setWindow,
yomitanSession,
});
return;
}
const extension = await deps.ensureYomitanExtensionLoaded();
if (!extension) {
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
return;
}
const yomitanSession = deps.getYomitanSession?.() ?? null;
if (!yomitanSession) {
deps.logWarn('Unable to open Yomitan settings: Yomitan session is unavailable.');
return;
}
deps.openYomitanSettingsWindow({
yomitanExt: extension,
getExistingWindow: deps.getExistingWindow,
@@ -36,14 +36,14 @@ test('yomitan settings runtime composes opener with built deps', async () => {
assert.deepEqual(calls, ['open-window:session']);
});
test('yomitan settings runtime warns and does not open when no yomitan session is available', async () => {
test('yomitan settings runtime opens with default session when no yomitan session is available', async () => {
let existingWindow: { id: string } | null = null;
const calls: string[] = [];
const runtime = createYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
openYomitanSettingsWindow: () => {
calls.push('open-window');
openYomitanSettingsWindow: ({ yomitanSession }) => {
calls.push(`open-window:${yomitanSession === null ? 'default-session' : 'custom-session'}`);
},
getExistingWindow: () => existingWindow as never,
setWindow: (window) => {
@@ -58,7 +58,5 @@ test('yomitan settings runtime warns and does not open when no yomitan session i
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(existingWindow, null);
assert.deepEqual(calls, [
'warn:Unable to open Yomitan settings: Yomitan session is unavailable.',
]);
assert.deepEqual(calls, ['open-window:default-session']);
});