mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
feat: add auto update support (#65)
This commit is contained in:
@@ -36,6 +36,9 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
loadYomitanExtension: async () => {
|
||||
calls.push('load-yomitan');
|
||||
},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('ensure-yomitan');
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
calls.push('handle-first-run-setup');
|
||||
},
|
||||
@@ -67,6 +70,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
onReady.createMpvClient();
|
||||
await onReady.createMecabTokenizerAndCheck();
|
||||
await onReady.loadYomitanExtension();
|
||||
await onReady.ensureYomitanExtensionLoaded?.();
|
||||
await onReady.handleFirstRunSetup();
|
||||
await onReady.prewarmSubtitleDictionaries?.();
|
||||
onReady.startBackgroundWarmups();
|
||||
@@ -79,6 +83,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
|
||||
'create-mpv-client',
|
||||
'create-mecab',
|
||||
'load-yomitan',
|
||||
'ensure-yomitan',
|
||||
'handle-first-run-setup',
|
||||
'prewarm-dicts',
|
||||
'start-warmups',
|
||||
|
||||
@@ -27,6 +27,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
|
||||
createImmersionTracker: deps.createImmersionTracker,
|
||||
startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
|
||||
loadYomitanExtension: deps.loadYomitanExtension,
|
||||
ensureYomitanExtensionLoaded: deps.ensureYomitanExtensionLoaded,
|
||||
handleFirstRunSetup: deps.handleFirstRunSetup,
|
||||
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups: deps.startBackgroundWarmups,
|
||||
|
||||
@@ -81,6 +81,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
return setTimeout(() => {}, 0);
|
||||
},
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -52,6 +52,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
}) {
|
||||
@@ -106,6 +107,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
|
||||
schedule: deps.schedule,
|
||||
logInfo: deps.logInfo,
|
||||
logDebug: deps.logDebug,
|
||||
logWarn: deps.logWarn,
|
||||
logError: deps.logError,
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
getMultiCopyTimeoutMs: () => 5000,
|
||||
schedule: (fn) => setTimeout(fn, 0),
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
@@ -30,7 +30,8 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
initializeOverlayRuntime: () => calls.push('init-overlay'),
|
||||
toggleVisibleOverlay: () => calls.push('toggle-visible'),
|
||||
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
|
||||
openFirstRunSetupWindow: () => calls.push('open-setup'),
|
||||
openFirstRunSetupWindow: (force?: boolean) =>
|
||||
calls.push(`open-setup:${force === true ? 'force' : 'default'}`),
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
|
||||
|
||||
copyCurrentSubtitle: () => calls.push('copy-sub'),
|
||||
@@ -110,6 +111,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
return setTimeout(() => {}, 0);
|
||||
},
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
});
|
||||
@@ -125,11 +127,19 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
assert.equal(deps.shouldOpenBrowser(), true);
|
||||
deps.showOsd('hello');
|
||||
deps.initializeOverlay();
|
||||
deps.openFirstRunSetup();
|
||||
deps.openFirstRunSetup(true);
|
||||
deps.setVisibleOverlay(true);
|
||||
deps.printHelp();
|
||||
await deps.runUpdateCommand({ update: true } as never, 'initial');
|
||||
|
||||
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'open-setup', 'set-visible:true', 'help']);
|
||||
assert.deepEqual(calls, [
|
||||
'osd:hello',
|
||||
'init-overlay',
|
||||
'open-setup:force',
|
||||
'set-visible:true',
|
||||
'help',
|
||||
'run-update',
|
||||
]);
|
||||
|
||||
const retry = await deps.retryAnilistQueueNow();
|
||||
assert.deepEqual(retry, { ok: true, message: 'ok' });
|
||||
|
||||
@@ -28,7 +28,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
openFirstRunSetupWindow: (force?: boolean) => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
|
||||
copyCurrentSubtitle: () => void;
|
||||
@@ -65,6 +65,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
}) {
|
||||
@@ -97,7 +98,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
initializeOverlay: () => deps.initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
|
||||
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
|
||||
openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
|
||||
openFirstRunSetup: (force?: boolean) => deps.openFirstRunSetupWindow(force),
|
||||
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
|
||||
@@ -134,6 +135,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(),
|
||||
schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
logDebug: (message: string) => deps.logDebug(message),
|
||||
logWarn: (message: string) => deps.logWarn(message),
|
||||
logError: (message: string, err: unknown) => deps.logError(message, err),
|
||||
});
|
||||
|
||||
@@ -66,6 +66,9 @@ function createDeps() {
|
||||
logInfo: (message: string) => {
|
||||
logs.push(`i:${message}`);
|
||||
},
|
||||
logDebug: (message: string) => {
|
||||
logs.push(`d:${message}`);
|
||||
},
|
||||
logWarn: (message: string) => {
|
||||
logs.push(`w:${message}`);
|
||||
},
|
||||
@@ -102,7 +105,8 @@ test('cli command context log methods map to deps loggers', () => {
|
||||
const { deps, getLogs } = createDeps();
|
||||
const context = createCliCommandContext(deps);
|
||||
context.log('info');
|
||||
context.logDebug('debug');
|
||||
context.warn('warn');
|
||||
context.error('error', new Error('x'));
|
||||
assert.deepEqual(getLogs(), ['i:info', 'w:warn', 'e:error']);
|
||||
assert.deepEqual(getLogs(), ['i:info', 'd:debug', 'w:warn', 'e:error']);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
initializeOverlay: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
togglePrimarySubtitleBar: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
setVisibleOverlay: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -57,6 +57,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
};
|
||||
@@ -133,6 +134,7 @@ export function createCliCommandContext(
|
||||
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
|
||||
schedule: deps.schedule,
|
||||
log: deps.logInfo,
|
||||
logDebug: deps.logDebug,
|
||||
warn: deps.logWarn,
|
||||
error: deps.logError,
|
||||
};
|
||||
|
||||
@@ -110,6 +110,21 @@ test('resolveBunInstallCommand uses Homebrew on macOS when available', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('detectBun reports homebrew install method from POSIX brew path', async () => {
|
||||
const snapshot = await detectBun({
|
||||
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');
|
||||
},
|
||||
runCommand: async () => ({ exitCode: 127, stdout: '', stderr: 'missing' }),
|
||||
});
|
||||
|
||||
assert.equal(snapshot.status, 'missing');
|
||||
assert.equal(snapshot.installMethod, 'homebrew');
|
||||
});
|
||||
|
||||
test('resolveLauncherInstallTarget prefers writable user bin on Linux', async () => {
|
||||
const target = await resolveLauncherInstallTarget({
|
||||
platform: 'linux',
|
||||
@@ -144,6 +159,53 @@ test('resolveLauncherInstallTarget returns not_installable without writable PATH
|
||||
assert.equal(target.installPath, null);
|
||||
});
|
||||
|
||||
test('resolveLauncherInstallTarget skips Homebrew bin for empty macOS manual installs', async () => {
|
||||
const target = await resolveLauncherInstallTarget({
|
||||
platform: 'darwin',
|
||||
homeDir: '/Users/tester',
|
||||
env: { PATH: '/opt/homebrew/bin:/usr/local/bin:/Users/tester/.local/bin:/usr/bin' },
|
||||
existsSync: (candidate) =>
|
||||
candidate === '/opt/homebrew/bin' ||
|
||||
candidate === '/usr/local/bin' ||
|
||||
candidate === '/Users/tester/.local/bin' ||
|
||||
candidate === '/usr/bin',
|
||||
accessSync: (candidate) => {
|
||||
if (
|
||||
candidate !== '/opt/homebrew/bin' &&
|
||||
candidate !== '/usr/local/bin' &&
|
||||
candidate !== '/Users/tester/.local/bin'
|
||||
) {
|
||||
throw new Error('not writable');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(target.status, 'not_installed');
|
||||
assert.equal(target.pathDir, '/Users/tester/.local/bin');
|
||||
assert.equal(target.installPath, '/Users/tester/.local/bin/subminer');
|
||||
});
|
||||
|
||||
test('resolveLauncherInstallTarget uses usr local bin for macOS manual install when user bin is absent', async () => {
|
||||
const target = await resolveLauncherInstallTarget({
|
||||
platform: 'darwin',
|
||||
homeDir: '/Users/tester',
|
||||
env: { PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin' },
|
||||
existsSync: (candidate) =>
|
||||
candidate === '/opt/homebrew/bin' ||
|
||||
candidate === '/usr/local/bin' ||
|
||||
candidate === '/usr/bin',
|
||||
accessSync: (candidate) => {
|
||||
if (candidate !== '/opt/homebrew/bin' && candidate !== '/usr/local/bin') {
|
||||
throw new Error('not writable');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(target.status, 'not_installed');
|
||||
assert.equal(target.pathDir, '/usr/local/bin');
|
||||
assert.equal(target.installPath, '/usr/local/bin/subminer');
|
||||
});
|
||||
|
||||
test('installLauncher writes Windows cmd shim and appends user PATH once', async () => {
|
||||
const files = new Map<string, string>();
|
||||
const dirs = new Set<string>();
|
||||
@@ -209,6 +271,54 @@ test('detectLauncher reports shadowed when another subminer appears earlier on P
|
||||
assert.equal(snapshot.installPath, '/home/tester/.local/bin/subminer');
|
||||
});
|
||||
|
||||
test('detectLauncher accepts installed macOS launcher from user local bin before Homebrew target', async () => {
|
||||
const snapshot = await detectLauncher({
|
||||
platform: 'darwin',
|
||||
homeDir: '/Users/tester',
|
||||
env: { PATH: '/Users/tester/.local/bin:/opt/homebrew/bin:/usr/bin' },
|
||||
existsSync: (candidate) =>
|
||||
candidate === '/Users/tester/.local/bin' ||
|
||||
candidate === '/opt/homebrew/bin' ||
|
||||
candidate === '/Users/tester/.local/bin/subminer',
|
||||
accessSync: () => undefined,
|
||||
runCommand: async (command, args) => {
|
||||
assert.equal(command, '/Users/tester/.local/bin/subminer');
|
||||
assert.deepEqual(args, ['--help']);
|
||||
return { exitCode: 0, stdout: 'help', stderr: '' };
|
||||
},
|
||||
bunSnapshot: createBunSnapshot('ready'),
|
||||
});
|
||||
|
||||
assert.equal(snapshot.status, 'ready');
|
||||
assert.equal(snapshot.commandPath, '/Users/tester/.local/bin/subminer');
|
||||
assert.equal(snapshot.installPath, '/Users/tester/.local/bin/subminer');
|
||||
assert.equal(snapshot.pathDir, '/Users/tester/.local/bin');
|
||||
assert.equal(snapshot.shadowedBy, null);
|
||||
});
|
||||
|
||||
test('detectLauncher accepts installed macOS launcher from Homebrew bin', async () => {
|
||||
const snapshot = await detectLauncher({
|
||||
platform: 'darwin',
|
||||
homeDir: '/Users/tester',
|
||||
env: { PATH: '/opt/homebrew/bin:/usr/bin' },
|
||||
existsSync: (candidate) =>
|
||||
candidate === '/opt/homebrew/bin' || candidate === '/opt/homebrew/bin/subminer',
|
||||
accessSync: () => undefined,
|
||||
runCommand: async (command, args) => {
|
||||
assert.equal(command, '/opt/homebrew/bin/subminer');
|
||||
assert.deepEqual(args, ['--help']);
|
||||
return { exitCode: 0, stdout: 'help', stderr: '' };
|
||||
},
|
||||
bunSnapshot: createBunSnapshot('ready'),
|
||||
});
|
||||
|
||||
assert.equal(snapshot.status, 'ready');
|
||||
assert.equal(snapshot.commandPath, '/opt/homebrew/bin/subminer');
|
||||
assert.equal(snapshot.installPath, '/opt/homebrew/bin/subminer');
|
||||
assert.equal(snapshot.pathDir, '/opt/homebrew/bin');
|
||||
assert.equal(snapshot.shadowedBy, null);
|
||||
});
|
||||
|
||||
test('detectLauncher reports installed_bun_missing when launcher exists but bun is missing', async () => {
|
||||
const snapshot = await detectLauncher({
|
||||
platform: 'linux',
|
||||
|
||||
@@ -72,21 +72,23 @@ const BUN_OFFICIAL_WINDOWS_COMMAND = [
|
||||
];
|
||||
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const COMMAND_TIMEOUT_MS = 15 * 1000;
|
||||
const MACOS_HOMEBREW_PATH_DIRS = ['/opt/homebrew/bin'];
|
||||
|
||||
function installMethodForCommand(
|
||||
command: string[] | null,
|
||||
): BunSnapshot['installMethod'] {
|
||||
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';
|
||||
const executable = path.basename(executablePath).toLowerCase();
|
||||
const windowsExecutable = path.win32.basename(executablePath).toLowerCase();
|
||||
if (windowsExecutable === 'winget.exe') return 'winget';
|
||||
if (windowsExecutable === 'scoop.cmd') return 'scoop';
|
||||
if (executable === 'brew' || windowsExecutable === 'brew') return 'homebrew';
|
||||
return 'official-script';
|
||||
}
|
||||
|
||||
export function resolveBunInstallCommand(options: CommonOptions = {}): BunSnapshot['installCommand'] {
|
||||
export function resolveBunInstallCommand(
|
||||
options: CommonOptions = {},
|
||||
): BunSnapshot['installCommand'] {
|
||||
const platform = platformOf(options);
|
||||
if (platform === 'win32') {
|
||||
const winget = findCommand('winget.exe', options);
|
||||
@@ -154,7 +156,8 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
|
||||
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 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');
|
||||
@@ -206,11 +209,47 @@ export async function resolveLauncherInstallTarget(
|
||||
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,
|
||||
: [
|
||||
path.posix.join(homeDir, '.local', 'bin'),
|
||||
path.posix.join(homeDir, 'bin'),
|
||||
'/usr/local/bin',
|
||||
];
|
||||
const manualPreferred =
|
||||
platform === 'darwin'
|
||||
? [
|
||||
path.posix.join(homeDir, '.local', 'bin'),
|
||||
path.posix.join(homeDir, 'bin'),
|
||||
'/usr/local/bin',
|
||||
]
|
||||
: preferred;
|
||||
const installCandidates = [...manualPreferred, ...pathDirs].filter(
|
||||
(dir, index, all) =>
|
||||
all.findIndex(
|
||||
(other) =>
|
||||
normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform),
|
||||
) === index,
|
||||
);
|
||||
const installedPreferred = pathDirs.find((dir) => {
|
||||
if (!pathEntriesContain(preferred, dir, platform)) return false;
|
||||
return existsSyncOf(options)(path.posix.join(dir, 'subminer'));
|
||||
});
|
||||
if (installedPreferred) {
|
||||
const installPath = path.posix.join(installedPreferred, 'subminer');
|
||||
return {
|
||||
status: 'ready',
|
||||
commandPath: installPath,
|
||||
installPath,
|
||||
pathDir: installedPreferred,
|
||||
shadowedBy: null,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
const selected = installCandidates.find(
|
||||
(dir) =>
|
||||
(platform !== 'darwin' || !pathEntriesContain(MACOS_HOMEBREW_PATH_DIRS, dir, platform)) &&
|
||||
pathEntriesContain(pathDirs, dir, platform) &&
|
||||
isWritableDir(dir, options),
|
||||
);
|
||||
const selected = candidates.find((dir) => pathEntriesContain(pathDirs, dir, platform) && isWritableDir(dir, options));
|
||||
if (!selected) {
|
||||
return {
|
||||
status: 'not_installable',
|
||||
@@ -258,10 +297,14 @@ export async function detectLauncher(
|
||||
|
||||
const commandPath = findCommand('subminer', options);
|
||||
const expectedNormalized = normalizePathForCompare(expectedPath, platform, platformPath);
|
||||
if (commandPath && normalizePathForCompare(commandPath, platform, platformPath) !== expectedNormalized) {
|
||||
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 (!existsSyncOf(options)(expectedPath))
|
||||
return { ...target, status: 'not_installed', commandPath: null };
|
||||
if (!commandPath) {
|
||||
return {
|
||||
...target,
|
||||
|
||||
@@ -11,6 +11,7 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
|
||||
return { ok: true, path: '/tmp/config.jsonc', warnings: [] };
|
||||
},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarning: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
startConfigHotReload: () => {},
|
||||
|
||||
@@ -58,6 +58,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
getMultiCopyTimeoutMs: () => 0,
|
||||
schedule: () => 0 as never,
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
},
|
||||
|
||||
@@ -82,6 +82,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
jellyfinPreviewAuth: false,
|
||||
texthooker: false,
|
||||
texthookerOpenBrowser: false,
|
||||
update: false,
|
||||
help: false,
|
||||
autoStartOverlay: false,
|
||||
generateConfig: false,
|
||||
@@ -124,6 +125,7 @@ test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
|
||||
});
|
||||
|
||||
test('shouldAutoOpenFirstRunSetup treats numeric startup counts as explicit commands', () => {
|
||||
|
||||
@@ -119,6 +119,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth ||
|
||||
args.texthooker ||
|
||||
args.update ||
|
||||
args.help,
|
||||
);
|
||||
}
|
||||
@@ -129,6 +130,10 @@ export function shouldAutoOpenFirstRunSetup(args: CliArgs): boolean {
|
||||
return !hasAnyStartupCommandBeyondSetup(args);
|
||||
}
|
||||
|
||||
export function isStandaloneFirstRunSetupCommand(args: CliArgs): boolean {
|
||||
return args.setup && !args.start && !hasAnyStartupCommandBeyondSetup(args);
|
||||
}
|
||||
|
||||
function getPluginStatus(
|
||||
state: SetupState,
|
||||
pluginInstalled: boolean,
|
||||
|
||||
@@ -65,6 +65,9 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
assert.match(html, /Open Yomitan Settings/);
|
||||
assert.match(html, /Finish setup/);
|
||||
assert.match(html, /disabled/);
|
||||
assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/);
|
||||
assert.match(html, /min-height:\s*100vh;/);
|
||||
assert.match(html, /box-sizing:\s*border-box;/);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
|
||||
@@ -305,19 +308,60 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions',
|
||||
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>/);
|
||||
assert.match(
|
||||
html,
|
||||
/<button class="primary" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml disables launcher install when no target is installable', () => {
|
||||
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({
|
||||
launcher: {
|
||||
status: 'not_installable',
|
||||
commandPath: null,
|
||||
installPath: null,
|
||||
pathDir: null,
|
||||
shadowedBy: null,
|
||||
message: 'No writable PATH directory found.',
|
||||
},
|
||||
}),
|
||||
message: null,
|
||||
});
|
||||
|
||||
assert.match(
|
||||
html,
|
||||
/<button disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=install-command-line-launcher'">Install launcher<\/button>/,
|
||||
);
|
||||
});
|
||||
|
||||
test('first-run setup window handler focuses existing window', () => {
|
||||
const calls: string[] = [];
|
||||
const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({
|
||||
getSetupWindow: () => ({
|
||||
show: () => calls.push('show'),
|
||||
focus: () => calls.push('focus'),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(maybeFocus(), true);
|
||||
assert.deepEqual(calls, ['focus']);
|
||||
assert.deepEqual(calls, ['show', 'focus']);
|
||||
});
|
||||
|
||||
test('first-run setup navigation handler prevents default and dispatches supported action', async () => {
|
||||
@@ -366,6 +410,138 @@ test('first-run setup navigation handler swallows stale custom-scheme actions',
|
||||
assert.deepEqual(calls, ['preventDefault']);
|
||||
});
|
||||
|
||||
test('opening first-run setup shows and focuses window after content loads', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createOpenFirstRunSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () =>
|
||||
({
|
||||
webContents: {
|
||||
on: () => {},
|
||||
},
|
||||
loadURL: async () => {
|
||||
calls.push('load');
|
||||
},
|
||||
on: () => {},
|
||||
isDestroyed: () => false,
|
||||
close: () => {},
|
||||
show: () => calls.push('show'),
|
||||
focus: () => calls.push('focus'),
|
||||
}) as never,
|
||||
getSetupSnapshot: async () => ({
|
||||
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(),
|
||||
message: null,
|
||||
}),
|
||||
buildSetupHtml: () => '<html></html>',
|
||||
parseSubmissionUrl: () => null,
|
||||
handleAction: async () => undefined,
|
||||
markSetupInProgress: async () => {
|
||||
calls.push('in-progress');
|
||||
},
|
||||
markSetupCancelled: async () => undefined,
|
||||
isSetupCompleted: () => true,
|
||||
shouldQuitWhenClosedIncomplete: () => false,
|
||||
quitApp: () => {},
|
||||
clearSetupWindow: () => {},
|
||||
setSetupWindow: () => {
|
||||
calls.push('set');
|
||||
},
|
||||
encodeURIComponent: (value) => value,
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
handler();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'load', 'show', 'focus']);
|
||||
});
|
||||
|
||||
test('opening first-run setup skips rendering if window is destroyed after snapshot', async () => {
|
||||
const calls: string[] = [];
|
||||
let destroyed = false;
|
||||
const handler = createOpenFirstRunSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () =>
|
||||
({
|
||||
webContents: {
|
||||
on: () => {},
|
||||
},
|
||||
loadURL: async () => {
|
||||
calls.push('load');
|
||||
},
|
||||
on: () => {},
|
||||
isDestroyed: () => destroyed,
|
||||
close: () => {},
|
||||
show: () => calls.push('show'),
|
||||
focus: () => calls.push('focus'),
|
||||
}) as never,
|
||||
getSetupSnapshot: async () => {
|
||||
calls.push('snapshot');
|
||||
destroyed = true;
|
||||
return {
|
||||
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(),
|
||||
message: null,
|
||||
};
|
||||
},
|
||||
buildSetupHtml: () => {
|
||||
calls.push('build');
|
||||
return '<html></html>';
|
||||
},
|
||||
parseSubmissionUrl: () => null,
|
||||
handleAction: async () => undefined,
|
||||
markSetupInProgress: async () => {
|
||||
calls.push('in-progress');
|
||||
},
|
||||
markSetupCancelled: async () => undefined,
|
||||
isSetupCompleted: () => true,
|
||||
shouldQuitWhenClosedIncomplete: () => false,
|
||||
quitApp: () => {},
|
||||
clearSetupWindow: () => {},
|
||||
setSetupWindow: () => {
|
||||
calls.push('set');
|
||||
},
|
||||
encodeURIComponent: (value) => value,
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
handler();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'snapshot']);
|
||||
});
|
||||
|
||||
test('closing incomplete first-run setup quits app outside background mode', async () => {
|
||||
const calls: string[] = [];
|
||||
let closedHandler: (() => void) | undefined;
|
||||
@@ -437,3 +613,76 @@ test('closing incomplete first-run setup quits app outside background mode', asy
|
||||
|
||||
assert.deepEqual(calls, ['set', 'cancelled', 'clear', 'quit']);
|
||||
});
|
||||
|
||||
test('closing completed first-run setup quits app when completion policy allows it', async () => {
|
||||
const calls: string[] = [];
|
||||
let closedHandler: (() => void) | undefined;
|
||||
const handler = createOpenFirstRunSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () =>
|
||||
({
|
||||
webContents: {
|
||||
on: () => {},
|
||||
},
|
||||
loadURL: async () => undefined,
|
||||
on: (event: 'closed', callback: () => void) => {
|
||||
if (event === 'closed') {
|
||||
closedHandler = callback;
|
||||
}
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
close: () => calls.push('close-window'),
|
||||
focus: () => {},
|
||||
}) as never,
|
||||
getSetupSnapshot: async () => ({
|
||||
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(),
|
||||
message: null,
|
||||
}),
|
||||
buildSetupHtml: () => '<html></html>',
|
||||
parseSubmissionUrl: () => null,
|
||||
handleAction: async () => undefined,
|
||||
markSetupInProgress: async () => undefined,
|
||||
markSetupCancelled: async () => {
|
||||
calls.push('cancelled');
|
||||
},
|
||||
isSetupCompleted: () => true,
|
||||
shouldQuitWhenClosedIncomplete: () => true,
|
||||
shouldQuitWhenClosedCompleted: () => true,
|
||||
quitApp: () => {
|
||||
calls.push('quit');
|
||||
},
|
||||
clearSetupWindow: () => {
|
||||
calls.push('clear');
|
||||
},
|
||||
setSetupWindow: () => {
|
||||
calls.push('set');
|
||||
},
|
||||
encodeURIComponent: (value) => value,
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
handler();
|
||||
if (typeof closedHandler !== 'function') {
|
||||
throw new Error('expected closed handler');
|
||||
}
|
||||
closedHandler();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(calls, ['set', 'clear', 'quit']);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
|
||||
type FocusableWindowLike = {
|
||||
focus: () => void;
|
||||
show?: () => void;
|
||||
};
|
||||
|
||||
type FirstRunSetupWebContentsLike = {
|
||||
@@ -124,7 +125,9 @@ function getLauncherTone(
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLauncherSnapshot): string {
|
||||
function renderCommandLineLauncherSection(
|
||||
commandLineLauncher: CommandLineLauncherSnapshot,
|
||||
): string {
|
||||
if (!commandLineLauncher.supported) {
|
||||
return '';
|
||||
}
|
||||
@@ -154,7 +157,7 @@ function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLaunch
|
||||
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' ? '' : '';
|
||||
const launcherButtonDisabled = launcher.status === 'not_installable' ? 'disabled' : '';
|
||||
|
||||
return `
|
||||
<section class="setup-section">
|
||||
@@ -345,13 +348,20 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
--yellow: #eed49f;
|
||||
--red: #ed8796;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, var(--mantle), var(--base));
|
||||
color: var(--text);
|
||||
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
}
|
||||
h1 {
|
||||
@@ -583,6 +593,7 @@ export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
|
||||
return (): boolean => {
|
||||
const window = deps.getSetupWindow();
|
||||
if (!window) return false;
|
||||
window.show?.();
|
||||
window.focus();
|
||||
return true;
|
||||
};
|
||||
@@ -626,6 +637,7 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
markSetupCancelled: () => Promise<unknown>;
|
||||
isSetupCompleted: () => boolean;
|
||||
shouldQuitWhenClosedIncomplete: () => boolean;
|
||||
shouldQuitWhenClosedCompleted?: () => boolean;
|
||||
quitApp: () => void;
|
||||
clearSetupWindow: () => void;
|
||||
setSetupWindow: (window: TWindow) => void;
|
||||
@@ -639,11 +651,23 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
|
||||
const setupWindow = deps.createSetupWindow();
|
||||
deps.setSetupWindow(setupWindow);
|
||||
setupWindow.show?.();
|
||||
setupWindow.focus();
|
||||
|
||||
const render = async (): Promise<void> => {
|
||||
const model = await deps.getSetupSnapshot();
|
||||
if (setupWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
const html = deps.buildSetupHtml(model);
|
||||
if (setupWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
await setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(html)}`);
|
||||
if (!setupWindow.isDestroyed()) {
|
||||
setupWindow.show?.();
|
||||
setupWindow.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
|
||||
@@ -682,7 +706,10 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
});
|
||||
}
|
||||
deps.clearSetupWindow();
|
||||
if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) {
|
||||
if (
|
||||
(setupCompleted && deps.shouldQuitWhenClosedCompleted?.()) ||
|
||||
(!setupCompleted && deps.shouldQuitWhenClosedIncomplete())
|
||||
) {
|
||||
deps.quitApp();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,8 +17,8 @@ test('createCreateFirstRunSetupWindowHandler builds first-run setup window', ()
|
||||
|
||||
assert.deepEqual(createSetupWindow(), { id: 'first-run' });
|
||||
assert.deepEqual(options, {
|
||||
width: 560,
|
||||
height: 640,
|
||||
width: 720,
|
||||
height: 860,
|
||||
title: 'SubMiner Setup',
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
|
||||
@@ -32,8 +32,8 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
||||
}) {
|
||||
return createSetupWindowHandler(deps, {
|
||||
width: 560,
|
||||
height: 640,
|
||||
width: 720,
|
||||
height: 860,
|
||||
title: 'SubMiner Setup',
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
|
||||
const deps = createBuildReloadConfigMainDepsHandler({
|
||||
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarning: (message) => calls.push(`warn:${message}`),
|
||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||
startConfigHotReload: () => calls.push('start-hot-reload'),
|
||||
@@ -30,6 +31,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
|
||||
warnings: [],
|
||||
});
|
||||
deps.logInfo('x');
|
||||
deps.logDebug('debug');
|
||||
deps.logWarning('y');
|
||||
deps.showDesktopNotification('SubMiner', { body: 'warn' });
|
||||
deps.startConfigHotReload();
|
||||
@@ -39,6 +41,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
|
||||
deps.failHandlers.quit();
|
||||
assert.deepEqual(calls, [
|
||||
'info:x',
|
||||
'debug:debug',
|
||||
'warn:y',
|
||||
'notify:SubMiner:warn',
|
||||
'start-hot-reload',
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createBuildReloadConfigMainDepsHandler(deps: ReloadConfigMainDep
|
||||
return (): ReloadConfigMainDeps => ({
|
||||
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
logDebug: (message: string) => deps.logDebug(message),
|
||||
logWarning: (message: string) => deps.logWarning(message),
|
||||
showDesktopNotification: (title: string, options: { body: string }) =>
|
||||
deps.showDesktopNotification(title, options),
|
||||
|
||||
@@ -20,6 +20,7 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
|
||||
],
|
||||
}),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarning: (message) => calls.push(`warn:${message}`),
|
||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||
startConfigHotReload: () => calls.push('hotReload:start'),
|
||||
@@ -36,7 +37,11 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
|
||||
reloadConfig();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.ok(calls.some((entry) => entry.startsWith('info:Using config file: /tmp/config.jsonc')));
|
||||
assert.ok(calls.some((entry) => entry.startsWith('debug:Using config file: /tmp/config.jsonc')));
|
||||
assert.equal(
|
||||
calls.some((entry) => entry.startsWith('info:Using config file: /tmp/config.jsonc')),
|
||||
false,
|
||||
);
|
||||
assert.ok(calls.some((entry) => entry.startsWith('warn:[config] Validation found 1 issue(s)')));
|
||||
assert.ok(
|
||||
calls.some((entry) => entry.includes('notify:SubMiner:1 config validation issue(s) detected.')),
|
||||
@@ -64,6 +69,7 @@ test('createReloadConfigHandler fails startup for parse errors', () => {
|
||||
error: 'unexpected token',
|
||||
}),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarning: (message) => calls.push(`warn:${message}`),
|
||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||
startConfigHotReload: () => calls.push('hotReload:start'),
|
||||
@@ -102,6 +108,7 @@ test('createReloadConfigHandler can skip AniList refresh for headless commands',
|
||||
warnings: [],
|
||||
}),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarning: (message) => calls.push(`warn:${message}`),
|
||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||
startConfigHotReload: () => calls.push('hotReload:start'),
|
||||
|
||||
@@ -24,6 +24,7 @@ type ReloadConfigStrictResult = ReloadConfigFailure | ReloadConfigSuccess;
|
||||
export type ReloadConfigRuntimeDeps = {
|
||||
reloadConfigStrict: () => ReloadConfigStrictResult;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
logWarning: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
startConfigHotReload: () => void;
|
||||
@@ -61,7 +62,7 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () =>
|
||||
);
|
||||
}
|
||||
|
||||
deps.logInfo(`Using config file: ${result.path}`);
|
||||
deps.logDebug(`Using config file: ${result.path}`);
|
||||
if (result.warnings.length > 0) {
|
||||
deps.logWarning(buildConfigWarningSummary(result.path, result.warnings));
|
||||
deps.showDesktopNotification('SubMiner', {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { configureAutoUpdater, type ElectronAutoUpdaterLike } from './app-updater';
|
||||
import {
|
||||
configureAutoUpdater,
|
||||
createElectronAppUpdater,
|
||||
isKnownLinuxPackageManagedAppImage,
|
||||
isNativeUpdaterSupported,
|
||||
resolveMacAppBundlePath,
|
||||
type ElectronAutoUpdaterLike,
|
||||
} from './app-updater';
|
||||
|
||||
type UpdaterLogger = {
|
||||
info: (message: string) => void;
|
||||
@@ -53,3 +60,222 @@ test('configureAutoUpdater allows prereleases only for the prerelease channel',
|
||||
configureAutoUpdater(updater, () => {}, 'stable');
|
||||
assert.equal(updater.allowPrerelease, false);
|
||||
});
|
||||
|
||||
test('configureAutoUpdater handles late updater error events', () => {
|
||||
const logged: string[] = [];
|
||||
const errorListeners: Array<(error: unknown) => void> = [];
|
||||
const updater: ElectronAutoUpdaterLike & {
|
||||
on: (event: string, listener: (error: unknown) => void) => typeof updater;
|
||||
} = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => null,
|
||||
downloadUpdate: async () => [],
|
||||
quitAndInstall: () => {},
|
||||
on: (event, listener) => {
|
||||
if (event === 'error') errorListeners.push(listener);
|
||||
return updater;
|
||||
},
|
||||
};
|
||||
|
||||
configureAutoUpdater(updater, (message) => logged.push(message));
|
||||
|
||||
const [errorListener] = errorListeners;
|
||||
assert.ok(errorListener);
|
||||
errorListener(new Error('APPIMAGE env is not defined'));
|
||||
assert.deepEqual(logged, ['Updater error event: APPIMAGE env is not defined']);
|
||||
});
|
||||
|
||||
test('app updater skips native update checks when native updater is unsupported', async () => {
|
||||
let checked = false;
|
||||
const updater: ElectronAutoUpdaterLike = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => {
|
||||
checked = true;
|
||||
return {
|
||||
updateInfo: {
|
||||
version: '0.15.0',
|
||||
},
|
||||
};
|
||||
},
|
||||
downloadUpdate: async () => [],
|
||||
quitAndInstall: () => {},
|
||||
};
|
||||
const logged: string[] = [];
|
||||
const appUpdater = createElectronAppUpdater({
|
||||
currentVersion: '0.14.0',
|
||||
isPackaged: true,
|
||||
updater,
|
||||
log: (message) => logged.push(message),
|
||||
isNativeUpdaterSupported: () => false,
|
||||
});
|
||||
|
||||
const result = await appUpdater.checkForUpdates('stable');
|
||||
|
||||
assert.equal(checked, false);
|
||||
assert.deepEqual(result, {
|
||||
available: false,
|
||||
version: '0.14.0',
|
||||
canUpdate: false,
|
||||
});
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native app update check because native updater is unsupported.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('app updater skips native downloads when native updater is unsupported', async () => {
|
||||
let downloaded = false;
|
||||
const updater: ElectronAutoUpdaterLike = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => null,
|
||||
downloadUpdate: async () => {
|
||||
downloaded = true;
|
||||
return [];
|
||||
},
|
||||
quitAndInstall: () => {},
|
||||
};
|
||||
const logged: string[] = [];
|
||||
const appUpdater = createElectronAppUpdater({
|
||||
currentVersion: '0.14.0',
|
||||
isPackaged: true,
|
||||
updater,
|
||||
log: (message) => logged.push(message),
|
||||
isNativeUpdaterSupported: () => false,
|
||||
});
|
||||
|
||||
await appUpdater.downloadUpdate();
|
||||
|
||||
assert.equal(downloaded, false);
|
||||
assert.deepEqual(logged, ['Skipping app update download because native updater is unsupported.']);
|
||||
});
|
||||
|
||||
test('resolveMacAppBundlePath resolves packaged macOS executable path', () => {
|
||||
assert.equal(
|
||||
resolveMacAppBundlePath('/Applications/SubMiner.app/Contents/MacOS/SubMiner'),
|
||||
'/Applications/SubMiner.app',
|
||||
);
|
||||
assert.equal(resolveMacAppBundlePath('/usr/local/bin/SubMiner'), null);
|
||||
});
|
||||
|
||||
test('mac native updater is unsupported for ad-hoc signed app bundles', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'darwin',
|
||||
isPackaged: true,
|
||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
readCodeSignature: () =>
|
||||
['Signature=adhoc', 'TeamIdentifier=not set', 'Runtime Version=26.0.0'].join('\n'),
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
|
||||
});
|
||||
|
||||
test('mac native updater is supported for Developer ID signed app bundles', async () => {
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'darwin',
|
||||
isPackaged: true,
|
||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
readCodeSignature: () =>
|
||||
['Authority=Developer ID Application: Example', 'TeamIdentifier=ABCDE12345'].join('\n'),
|
||||
});
|
||||
|
||||
assert.equal(supported, true);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {
|
||||
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
|
||||
},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported when APPIMAGE is missing', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported for non-writable AppImage installs', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {
|
||||
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
|
||||
},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported for package-managed AppImage installs', async () => {
|
||||
const logged: string[] = [];
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'linux',
|
||||
isPackaged: true,
|
||||
execPath: '/tmp/.mount_SubMiner/SubMiner',
|
||||
env: {
|
||||
APPIMAGE: '/opt/SubMiner/SubMiner.AppImage',
|
||||
},
|
||||
log: (message) => logged.push(message),
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
assert.deepEqual(logged, [
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('known Linux package-managed AppImage detection follows the canonical AUR path', () => {
|
||||
assert.equal(isKnownLinuxPackageManagedAppImage('/opt/SubMiner/SubMiner.AppImage'), true);
|
||||
assert.equal(
|
||||
isKnownLinuxPackageManagedAppImage('/home/tester/.local/bin/SubMiner.AppImage'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('native updater is unsupported on Windows by default', async () => {
|
||||
const supported = await isNativeUpdaterSupported({
|
||||
platform: 'win32',
|
||||
isPackaged: true,
|
||||
execPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
|
||||
});
|
||||
|
||||
assert.equal(supported, false);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { autoUpdater as electronAutoUpdater } from 'electron-updater';
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
import { compareSemverLike } from './release-assets';
|
||||
@@ -20,6 +23,9 @@ export interface ElectronAutoUpdaterLike {
|
||||
allowPrerelease: boolean;
|
||||
allowDowngrade: boolean;
|
||||
logger?: ElectronUpdaterLoggerLike | null;
|
||||
on?: (event: 'error', listener: (error: unknown) => void) => unknown;
|
||||
off?: (event: 'error', listener: (error: unknown) => void) => unknown;
|
||||
removeListener?: (event: 'error', listener: (error: unknown) => void) => unknown;
|
||||
checkForUpdates: () => Promise<{
|
||||
updateInfo?: {
|
||||
version?: string;
|
||||
@@ -29,6 +35,85 @@ export interface ElectronAutoUpdaterLike {
|
||||
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
|
||||
}
|
||||
|
||||
const updaterErrorListeners = new WeakMap<object, (error: unknown) => void>();
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function resolveMacAppBundlePath(execPath: string): string | null {
|
||||
const marker = '.app/Contents/MacOS/';
|
||||
const markerIndex = execPath.indexOf(marker);
|
||||
if (markerIndex < 0) return null;
|
||||
return execPath.slice(0, markerIndex + '.app'.length);
|
||||
}
|
||||
|
||||
async function readMacCodeSignature(appBundlePath: string): Promise<string | null> {
|
||||
try {
|
||||
const result = await execFileAsync('/usr/bin/codesign', ['-dv', '--verbose=4', appBundlePath], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function realpathOrOriginal(filePath: string): string {
|
||||
try {
|
||||
return realpathSync(filePath);
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
export function isKnownLinuxPackageManagedAppImage(appImagePath: string): boolean {
|
||||
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
|
||||
}
|
||||
|
||||
export async function isNativeUpdaterSupported(options: {
|
||||
platform: NodeJS.Platform;
|
||||
isPackaged: boolean;
|
||||
execPath: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
readCodeSignature?: (appBundlePath: string) => string | null | Promise<string | null>;
|
||||
log?: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
if (!options.isPackaged) {
|
||||
options.log?.('Skipping native updater because this build is not packaged.');
|
||||
return false;
|
||||
}
|
||||
if (options.platform === 'linux') {
|
||||
options.log?.(
|
||||
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (options.platform !== 'darwin') {
|
||||
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const appBundlePath = resolveMacAppBundlePath(options.execPath);
|
||||
if (!appBundlePath) {
|
||||
options.log?.(
|
||||
'Skipping native macOS updater because the app bundle path could not be resolved.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
|
||||
if (!signature) {
|
||||
options.log?.(
|
||||
'Skipping native macOS updater because the app code signature could not be read.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (/Signature=adhoc\b/.test(signature) || /TeamIdentifier=not set\b/.test(signature)) {
|
||||
options.log?.('Skipping native macOS updater because this build is ad-hoc signed.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function configureAutoUpdater(
|
||||
updater: ElectronAutoUpdaterLike,
|
||||
log: (message: string) => void = () => {},
|
||||
@@ -43,6 +128,22 @@ export function configureAutoUpdater(
|
||||
warn: (message) => log(message),
|
||||
error: (message) => log(message),
|
||||
};
|
||||
const previousErrorListener = updaterErrorListeners.get(updater);
|
||||
if (previousErrorListener) {
|
||||
if (updater.off) {
|
||||
updater.off('error', previousErrorListener);
|
||||
} else {
|
||||
updater.removeListener?.('error', previousErrorListener);
|
||||
}
|
||||
}
|
||||
if (updater.on) {
|
||||
const errorListener = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log(`Updater error event: ${message}`);
|
||||
};
|
||||
updater.on('error', errorListener);
|
||||
updaterErrorListeners.set(updater, errorListener);
|
||||
}
|
||||
return updater;
|
||||
}
|
||||
|
||||
@@ -52,6 +153,7 @@ export function createElectronAppUpdater(options: {
|
||||
updater?: ElectronAutoUpdaterLike;
|
||||
log: (message: string) => void;
|
||||
getChannel?: () => UpdateChannel;
|
||||
isNativeUpdaterSupported?: () => boolean | Promise<boolean>;
|
||||
}) {
|
||||
const getChannel = options.getChannel ?? (() => 'stable' as const);
|
||||
const updater = configureAutoUpdater(
|
||||
@@ -59,6 +161,15 @@ export function createElectronAppUpdater(options: {
|
||||
options.log,
|
||||
getChannel(),
|
||||
);
|
||||
let nativeUpdaterSupported: Promise<boolean> | null = null;
|
||||
|
||||
async function getNativeUpdaterSupported(): Promise<boolean> {
|
||||
if (!options.isNativeUpdaterSupported) return true;
|
||||
if (nativeUpdaterSupported === null) {
|
||||
nativeUpdaterSupported = Promise.resolve(options.isNativeUpdaterSupported());
|
||||
}
|
||||
return nativeUpdaterSupported;
|
||||
}
|
||||
|
||||
return {
|
||||
async checkForUpdates(channel?: UpdateChannel): Promise<AppUpdateCheckResult> {
|
||||
@@ -69,6 +180,14 @@ export function createElectronAppUpdater(options: {
|
||||
canUpdate: false,
|
||||
};
|
||||
}
|
||||
if (!(await getNativeUpdaterSupported())) {
|
||||
options.log('Skipping native app update check because native updater is unsupported.');
|
||||
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;
|
||||
@@ -83,9 +202,21 @@ export function createElectronAppUpdater(options: {
|
||||
options.log('Skipping app update download because this build is not packaged.');
|
||||
return;
|
||||
}
|
||||
if (!(await getNativeUpdaterSupported())) {
|
||||
options.log('Skipping app update download because native updater is unsupported.');
|
||||
return;
|
||||
}
|
||||
await updater.downloadUpdate();
|
||||
},
|
||||
quitAndInstall(): void {
|
||||
async quitAndInstall(): Promise<void> {
|
||||
if (!options.isPackaged) {
|
||||
options.log('Skipping app update install because this build is not packaged.');
|
||||
return;
|
||||
}
|
||||
if (!(await getNativeUpdaterSupported())) {
|
||||
options.log('Skipping app update install because native updater is unsupported.');
|
||||
return;
|
||||
}
|
||||
updater.quitAndInstall(false, true);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { buildProtectedAppImageUpdateCommand, updateAppImageFromRelease } from './appimage-updater';
|
||||
|
||||
const appImageBytes = Buffer.from('appimage');
|
||||
const appImageHash = createHash('sha256').update(appImageBytes).digest('hex');
|
||||
|
||||
test('updateAppImageFromRelease verifies hash and atomically replaces writable AppImage', async () => {
|
||||
const writes: Array<{ path: string; data: Buffer }> = [];
|
||||
const chmods: Array<{ path: string; mode: number }> = [];
|
||||
const renames: Array<{ from: string; to: string }> = [];
|
||||
|
||||
const result = await updateAppImageFromRelease({
|
||||
release: {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
|
||||
},
|
||||
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
|
||||
appImagePath: '/home/kyle/.local/bin/SubMiner.AppImage',
|
||||
downloadAsset: async () => appImageBytes,
|
||||
fs: {
|
||||
stat: async () => ({
|
||||
isFile: () => true,
|
||||
mode: 0o755,
|
||||
}),
|
||||
access: async () => {},
|
||||
writeFile: async (targetPath, data) => {
|
||||
writes.push({ path: targetPath, data });
|
||||
},
|
||||
chmod: async (targetPath, mode) => {
|
||||
chmods.push({ path: targetPath, mode });
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
renames.push({ from, to });
|
||||
},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
status: 'updated',
|
||||
path: '/home/kyle/.local/bin/SubMiner.AppImage',
|
||||
});
|
||||
assert.deepEqual(writes, [
|
||||
{ path: '/home/kyle/.local/bin/.SubMiner.AppImage.update', data: appImageBytes },
|
||||
]);
|
||||
assert.deepEqual(chmods, [
|
||||
{ path: '/home/kyle/.local/bin/.SubMiner.AppImage.update', mode: 0o755 },
|
||||
]);
|
||||
assert.deepEqual(renames, [
|
||||
{
|
||||
from: '/home/kyle/.local/bin/.SubMiner.AppImage.update',
|
||||
to: '/home/kyle/.local/bin/SubMiner.AppImage',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('updateAppImageFromRelease reports protected command without replacing non-writable AppImage', async () => {
|
||||
const result = await updateAppImageFromRelease({
|
||||
release: {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
|
||||
},
|
||||
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
|
||||
appImagePath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
downloadAsset: async () => appImageBytes,
|
||||
fs: {
|
||||
stat: async () => ({
|
||||
isFile: () => true,
|
||||
mode: 0o755,
|
||||
}),
|
||||
access: async () => {
|
||||
throw new Error('EACCES');
|
||||
},
|
||||
writeFile: async () => {
|
||||
throw new Error('unexpected write');
|
||||
},
|
||||
chmod: async () => {},
|
||||
rename: async () => {},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'protected');
|
||||
assert.equal(result.path, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.match(result.command ?? '', /curl -fSL 'https:\/\/example\.test\/app' -o "\$tmp"/);
|
||||
assert.match(result.command ?? '', /sha256sum -c -/);
|
||||
assert.match(result.command ?? '', /sudo mv "\$tmp" '\/opt\/SubMiner\/SubMiner\.AppImage'/);
|
||||
});
|
||||
|
||||
test('buildProtectedAppImageUpdateCommand quotes inputs and verifies checksum before sudo move', () => {
|
||||
const command = buildProtectedAppImageUpdateCommand(
|
||||
"https://example.test/Sub Miner.AppImage?sig='abc'",
|
||||
"/opt/Sub Miner/SubMiner's.AppImage",
|
||||
'ABCDEF',
|
||||
);
|
||||
|
||||
assert.match(command, /trap 'rm -f "\$tmp"' EXIT/);
|
||||
assert.match(
|
||||
command,
|
||||
/curl -fSL 'https:\/\/example\.test\/Sub Miner\.AppImage\?sig='\\''abc'\\''' -o "\$tmp"/,
|
||||
);
|
||||
assert.match(command, /printf '%s %s\\n' 'abcdef' "\$tmp" \| sha256sum -c -/);
|
||||
assert.match(command, /sudo mv "\$tmp" '\/opt\/Sub Miner\/SubMiner'\\''s\.AppImage'/);
|
||||
assert.match(command, /sudo chmod \+x '\/opt\/Sub Miner\/SubMiner'\\''s\.AppImage'/);
|
||||
});
|
||||
|
||||
test('updateAppImageFromRelease aborts on hash mismatch', async () => {
|
||||
const result = await updateAppImageFromRelease({
|
||||
release: {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
|
||||
},
|
||||
sha256Sums: new Map([['SubMiner.AppImage', '0'.repeat(64)]]),
|
||||
appImagePath: '/home/kyle/.local/bin/SubMiner.AppImage',
|
||||
downloadAsset: async () => appImageBytes,
|
||||
fs: {
|
||||
stat: async () => ({
|
||||
isFile: () => true,
|
||||
mode: 0o755,
|
||||
}),
|
||||
access: async () => {},
|
||||
writeFile: async () => {
|
||||
throw new Error('unexpected write');
|
||||
},
|
||||
chmod: async () => {},
|
||||
rename: async () => {},
|
||||
unlink: async () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'hash-mismatch');
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { GitHubRelease } from './release-assets';
|
||||
import { findReleaseAsset } from './release-assets';
|
||||
|
||||
type StatLike = {
|
||||
isFile: () => boolean;
|
||||
mode?: number;
|
||||
};
|
||||
|
||||
export type AppImageUpdateStatus =
|
||||
| 'updated'
|
||||
| 'skipped'
|
||||
| 'protected'
|
||||
| 'hash-mismatch'
|
||||
| 'not-found'
|
||||
| 'missing-asset';
|
||||
|
||||
export interface AppImageUpdateResult {
|
||||
status: AppImageUpdateStatus;
|
||||
path?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface AppImageUpdateFileSystem {
|
||||
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>;
|
||||
}
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function defaultFs(): AppImageUpdateFileSystem {
|
||||
return {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export function buildProtectedAppImageUpdateCommand(
|
||||
assetUrl: string,
|
||||
appImagePath: string,
|
||||
expectedSha256: string,
|
||||
): string {
|
||||
const quotedUrl = shellQuote(assetUrl);
|
||||
const quotedPath = shellQuote(appImagePath);
|
||||
const quotedSha256 = shellQuote(expectedSha256.toLowerCase());
|
||||
return [
|
||||
'tmp=$(mktemp)',
|
||||
'trap \'rm -f "$tmp"\' EXIT',
|
||||
`curl -fSL ${quotedUrl} -o "$tmp"`,
|
||||
`printf '%s %s\\n' ${quotedSha256} "$tmp" | sha256sum -c -`,
|
||||
`sudo mv "$tmp" ${quotedPath}`,
|
||||
`sudo chmod +x ${quotedPath}`,
|
||||
].join(' && ');
|
||||
}
|
||||
|
||||
function selectAppImageAsset(release: GitHubRelease, appImagePath: string) {
|
||||
const basename = path.basename(appImagePath);
|
||||
return (
|
||||
findReleaseAsset(release, basename) ??
|
||||
findReleaseAsset(release, 'SubMiner.AppImage') ??
|
||||
release.assets.find((asset) => asset.name.endsWith('.AppImage')) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateAppImageFromRelease(options: {
|
||||
release: GitHubRelease | null;
|
||||
sha256Sums: Map<string, string>;
|
||||
appImagePath?: string;
|
||||
downloadAsset: (url: string) => Promise<Buffer>;
|
||||
fs?: AppImageUpdateFileSystem;
|
||||
}): Promise<AppImageUpdateResult> {
|
||||
if (!options.appImagePath) {
|
||||
return { status: 'not-found', message: 'No AppImage path detected.' };
|
||||
}
|
||||
if (!options.release) return { status: 'missing-asset', message: 'No release found.' };
|
||||
|
||||
const asset = selectAppImageAsset(options.release, options.appImagePath);
|
||||
if (!asset) return { status: 'missing-asset', message: 'Release has no AppImage asset.' };
|
||||
|
||||
const expectedSha256 = options.sha256Sums.get(asset.name);
|
||||
if (!expectedSha256) {
|
||||
return { status: 'missing-asset', message: `SHA256SUMS.txt has no ${asset.name} entry.` };
|
||||
}
|
||||
|
||||
const fsDeps = options.fs ?? defaultFs();
|
||||
let stat: StatLike;
|
||||
try {
|
||||
stat = await fsDeps.stat(options.appImagePath);
|
||||
} catch {
|
||||
return { status: 'not-found', path: options.appImagePath };
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
return { status: 'skipped', path: options.appImagePath, message: 'AppImage is not a file.' };
|
||||
}
|
||||
|
||||
try {
|
||||
await fsDeps.access(options.appImagePath);
|
||||
} catch {
|
||||
return {
|
||||
status: 'protected',
|
||||
path: options.appImagePath,
|
||||
command: buildProtectedAppImageUpdateCommand(
|
||||
asset.browser_download_url,
|
||||
options.appImagePath,
|
||||
expectedSha256,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const data = await options.downloadAsset(asset.browser_download_url);
|
||||
const actualSha256 = sha256(data);
|
||||
if (actualSha256 !== expectedSha256.toLowerCase()) {
|
||||
return {
|
||||
status: 'hash-mismatch',
|
||||
path: options.appImagePath,
|
||||
message: `Expected ${expectedSha256}, got ${actualSha256}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const tempPath = path.join(
|
||||
path.dirname(options.appImagePath),
|
||||
`.${path.basename(options.appImagePath)}.update`,
|
||||
);
|
||||
try {
|
||||
await fsDeps.writeFile(tempPath, data);
|
||||
await fsDeps.chmod(tempPath, stat.mode ? stat.mode & 0o777 : 0o755);
|
||||
await fsDeps.rename(tempPath, options.appImagePath);
|
||||
return { status: 'updated', path: options.appImagePath };
|
||||
} catch (error) {
|
||||
await fsDeps.unlink(tempPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,13 @@ test('looksLikeSubminerLauncher rejects unrelated executable content', () => {
|
||||
assert.equal(looksLikeSubminerLauncher(Buffer.from('SubMiner launcher binary payload')), true);
|
||||
});
|
||||
|
||||
test('buildProtectedLauncherUpdateCommand uses sudo curl and chmod for protected paths', () => {
|
||||
test('buildProtectedLauncherUpdateCommand quotes sudo curl and chmod paths', () => {
|
||||
assert.equal(
|
||||
buildProtectedLauncherUpdateCommand(
|
||||
'https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer',
|
||||
'/usr/local/bin/subminer',
|
||||
"https://github.com/ksyasuda/SubMiner/releases/latest/download/sub miner?sig='abc'",
|
||||
"/usr/local/bin/subminer's launcher",
|
||||
),
|
||||
'sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer && sudo chmod +x /usr/local/bin/subminer',
|
||||
"sudo curl -fSL 'https://github.com/ksyasuda/SubMiner/releases/latest/download/sub miner?sig='\\''abc'\\''' -o '/usr/local/bin/subminer'\\''s launcher' && sudo chmod +x '/usr/local/bin/subminer'\\''s launcher'",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ test('updateLauncherAtPath reports protected command without replacing non-writa
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'protected');
|
||||
assert.match(result.command ?? '', /^sudo curl -fSL https:\/\/example\.test\/subminer/);
|
||||
assert.match(result.command ?? '', /^sudo curl -fSL 'https:\/\/example\.test\/subminer'/);
|
||||
});
|
||||
|
||||
test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher content', async () => {
|
||||
|
||||
@@ -50,13 +50,17 @@ export function buildProtectedLauncherUpdateCommand(
|
||||
assetUrl: string,
|
||||
launcherPath: string,
|
||||
): string {
|
||||
return `sudo curl -fSL ${assetUrl} -o ${launcherPath} && sudo chmod +x ${launcherPath}`;
|
||||
return `sudo curl -fSL ${shellQuote(assetUrl)} -o ${shellQuote(launcherPath)} && sudo chmod +x ${shellQuote(launcherPath)}`;
|
||||
}
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function defaultFs(): LauncherUpdateFileSystem {
|
||||
return {
|
||||
readFile: (targetPath) => fs.promises.readFile(targetPath),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildProtectedSupportAssetsCommand,
|
||||
detectSupportAssetDataDirs,
|
||||
updateSupportAssetsFromRelease,
|
||||
} from './support-assets';
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function makeSupportAssetsArchive(): { archive: Buffer; tempDir: string } {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-support-assets-test-'));
|
||||
fs.mkdirSync(path.join(tempDir, 'assets/themes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempDir, 'plugin/subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'assets/themes/subminer.rasi'), 'new theme\n');
|
||||
fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), 'new plugin\n');
|
||||
execFileSync('tar', ['-czf', 'subminer-assets.tar.gz', 'assets', 'plugin'], { cwd: tempDir });
|
||||
return {
|
||||
archive: fs.readFileSync(path.join(tempDir, 'subminer-assets.tar.gz')),
|
||||
tempDir,
|
||||
};
|
||||
}
|
||||
|
||||
test('detectSupportAssetDataDirs only returns Linux rofi theme locations', () => {
|
||||
assert.deepEqual(
|
||||
detectSupportAssetDataDirs({
|
||||
platform: 'darwin',
|
||||
homeDir: '/Users/kyle',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
detectSupportAssetDataDirs({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/kyle',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
}),
|
||||
['/tmp/xdg-data/SubMiner', '/usr/local/share/SubMiner', '/usr/share/SubMiner'],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildProtectedSupportAssetsCommand cleans up temporary extraction directory', () => {
|
||||
const command = buildProtectedSupportAssetsCommand(
|
||||
"https://example.test/subminer assets.tar.gz?sig='abc'",
|
||||
"/usr/local/share/SubMiner's data",
|
||||
);
|
||||
|
||||
assert.match(command, /tmp=\$\(mktemp -d\)/);
|
||||
assert.match(command, /trap 'rm -rf "\$tmp"' EXIT/);
|
||||
assert.match(
|
||||
command,
|
||||
/curl -fSL 'https:\/\/example\.test\/subminer assets\.tar\.gz\?sig='\\''abc'\\''' -o "\$tmp\/subminer-assets\.tar\.gz"/,
|
||||
);
|
||||
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/themes/);
|
||||
});
|
||||
|
||||
test('updateSupportAssetsFromRelease updates only the Linux rofi theme', async () => {
|
||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||
const dataDir = path.join(xdgDataHome, 'SubMiner');
|
||||
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
|
||||
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n');
|
||||
const { archive, tempDir } = makeSupportAssetsArchive();
|
||||
|
||||
try {
|
||||
const results = await updateSupportAssetsFromRelease({
|
||||
release: {
|
||||
tag_name: 'v0.15.0',
|
||||
assets: [
|
||||
{
|
||||
name: 'subminer-assets.tar.gz',
|
||||
browser_download_url: 'https://example.test/subminer-assets.tar.gz',
|
||||
},
|
||||
],
|
||||
},
|
||||
sha256Sums: new Map([['subminer-assets.tar.gz', sha256(archive)]]),
|
||||
downloadAsset: async () => archive,
|
||||
platform: 'linux',
|
||||
xdgDataHome,
|
||||
});
|
||||
|
||||
assert.deepEqual(results, [{ status: 'updated', path: dataDir }]);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
|
||||
'new theme\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
|
||||
'old plugin\n',
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -29,12 +29,6 @@ export function detectSupportAssetDataDirs(options: {
|
||||
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'];
|
||||
@@ -46,10 +40,10 @@ export function buildProtectedSupportAssetsCommand(assetUrl: string, dataDir: st
|
||||
const quotedDir = shellQuote(dataDir);
|
||||
return [
|
||||
'tmp=$(mktemp -d)',
|
||||
'trap \'rm -rf "$tmp"\' EXIT',
|
||||
`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 mkdir -p ${quotedDir}/themes`,
|
||||
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
|
||||
].join(' && ');
|
||||
}
|
||||
@@ -76,12 +70,15 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
homeDir?: string;
|
||||
xdgDataHome?: string;
|
||||
}): Promise<SupportAssetsUpdateResult[]> {
|
||||
if ((options.platform ?? process.platform) !== 'linux') {
|
||||
return [{ status: 'skipped', message: 'Support assets are only installed on Linux.' }];
|
||||
}
|
||||
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.' }];
|
||||
if (!asset) return [{ status: 'missing-asset', message: 'Release has no rofi theme asset.' }];
|
||||
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
|
||||
if (!expectedSha256) {
|
||||
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no support assets entry.' }];
|
||||
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no rofi theme entry.' }];
|
||||
}
|
||||
|
||||
const dataDirs = detectSupportAssetDataDirs({
|
||||
@@ -91,12 +88,11 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
});
|
||||
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 (hasTheme) existingDataDirs.push(dataDir);
|
||||
}
|
||||
if (existingDataDirs.length === 0) {
|
||||
return [{ status: 'skipped', message: 'No existing support asset install detected.' }];
|
||||
return [{ status: 'skipped', message: 'No existing rofi theme install detected.' }];
|
||||
}
|
||||
|
||||
const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs
|
||||
@@ -139,17 +135,8 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createUpdateDialogPresenter, type ShowMessageBox } from './update-dialogs';
|
||||
|
||||
test('update dialog presenter focuses app before showing macOS dialogs', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'darwin',
|
||||
focusApp: () => calls.push('focus'),
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, ['focus', 'dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('update dialog presenter does not focus app before showing non-macOS dialogs', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'linux',
|
||||
focusApp: () => calls.push('focus'),
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
@@ -15,6 +15,12 @@ export type ShowMessageBox = (options: {
|
||||
cancelId?: number;
|
||||
}) => Promise<MessageBoxResultLike>;
|
||||
|
||||
export interface UpdateDialogPresenterDeps {
|
||||
showMessageBox: ShowMessageBox;
|
||||
focusApp?: () => void;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
export async function showNoUpdateDialog(
|
||||
showMessageBox: ShowMessageBox,
|
||||
version: string,
|
||||
@@ -27,6 +33,27 @@ export async function showNoUpdateDialog(
|
||||
});
|
||||
}
|
||||
|
||||
function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): void {
|
||||
if ((deps.platform ?? process.platform) !== 'darwin') return;
|
||||
deps.focusApp?.();
|
||||
}
|
||||
|
||||
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
||||
const showFocusedMessageBox: ShowMessageBox = async (options) => {
|
||||
maybeFocusAppForDialog(deps);
|
||||
return deps.showMessageBox(options);
|
||||
};
|
||||
|
||||
return {
|
||||
showNoUpdateDialog: (version: string) => showNoUpdateDialog(showFocusedMessageBox, version),
|
||||
showUpdateAvailableDialog: (version: string) =>
|
||||
showUpdateAvailableDialog(showFocusedMessageBox, version),
|
||||
showUpdateFailedDialog: (message: string) =>
|
||||
showUpdateFailedDialog(showFocusedMessageBox, message),
|
||||
showRestartDialog: () => showRestartDialog(showFocusedMessageBox),
|
||||
};
|
||||
}
|
||||
|
||||
export async function showUpdateAvailableDialog(
|
||||
showMessageBox: ShowMessageBox,
|
||||
version: string,
|
||||
|
||||
@@ -47,3 +47,24 @@ test('notifyUpdateAvailable logs osd fallback when overlay notification fails',
|
||||
|
||||
assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']);
|
||||
});
|
||||
|
||||
test('notifyUpdateAvailable logs non-error osd failures with thrown value', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await notifyUpdateAvailable(
|
||||
{ notificationType: 'osd', version: '0.15.0' },
|
||||
{
|
||||
showSystemNotification: () => {
|
||||
calls.push('system');
|
||||
},
|
||||
showOsdNotification: async () => {
|
||||
throw 'mpv disconnected';
|
||||
},
|
||||
log: (message) => {
|
||||
calls.push(message);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,8 @@ export async function notifyUpdateAvailable(
|
||||
try {
|
||||
await deps.showOsdNotification(message);
|
||||
} catch (error) {
|
||||
deps.log(`Update OSD notification failed: ${(error as Error).message}`);
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
deps.log(`Update OSD notification failed: ${reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,9 @@ function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
|
||||
calls.push('restart-dialog');
|
||||
return 'later';
|
||||
},
|
||||
quitAndInstall: () => calls.push('quit-install'),
|
||||
quitAndInstall: () => {
|
||||
calls.push('quit-install');
|
||||
},
|
||||
notifyUpdateAvailable: async (version) => {
|
||||
calls.push(`notify:${version}`);
|
||||
},
|
||||
@@ -90,6 +92,32 @@ test('manual update check falls back to GitHub release when app metadata is unav
|
||||
assert.deepEqual(calls, ['available-dialog:0.15.0']);
|
||||
});
|
||||
|
||||
test('manual update check reports available when no update asset was applied', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
|
||||
fetchLatestStableRelease: async () => ({
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
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, 'update-available');
|
||||
assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable']);
|
||||
});
|
||||
|
||||
test('automatic update check skips inside configured interval', async () => {
|
||||
const { deps, calls, setState } = createDeps();
|
||||
setState({ lastAutomaticCheckAt: 1_000_000 - 60 * 60 * 1000 });
|
||||
@@ -141,6 +169,57 @@ test('concurrent update checks share one in-flight check', async () => {
|
||||
assert.equal(checkCount, 1);
|
||||
});
|
||||
|
||||
test('manual update check does not reuse in-flight automatic check', async () => {
|
||||
let checkCount = 0;
|
||||
const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = [];
|
||||
const { deps } = createDeps({
|
||||
checkAppUpdate: () =>
|
||||
new Promise((resolve) => {
|
||||
checkCount += 1;
|
||||
resolveChecks.push(resolve);
|
||||
}),
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
const automatic = service.checkForUpdates({ source: 'automatic', force: true });
|
||||
const manual = service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(checkCount, 2);
|
||||
for (const resolve of resolveChecks) {
|
||||
resolve({ available: false, version: '0.14.0' });
|
||||
}
|
||||
await Promise.all([automatic, manual]);
|
||||
});
|
||||
|
||||
test('manual update check passes selected GitHub release to launcher update', async () => {
|
||||
const selectedRelease = {
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
};
|
||||
let forwardedRelease: unknown;
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
|
||||
fetchLatestStableRelease: async () => selectedRelease,
|
||||
showUpdateAvailableDialog: async (version) => {
|
||||
calls.push(`available-dialog:${version}`);
|
||||
return 'update';
|
||||
},
|
||||
updateLauncher: (async (...args: unknown[]) => {
|
||||
calls.push(`launcher:${args[1]}`);
|
||||
forwardedRelease = args[2];
|
||||
return { status: 'updated' };
|
||||
}) as UpdateServiceDeps['updateLauncher'],
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.equal(forwardedRelease, selectedRelease);
|
||||
});
|
||||
|
||||
test('manual prerelease update check uses prerelease release and launcher channel', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
getConfig: () => ({
|
||||
|
||||
@@ -43,13 +43,14 @@ export interface UpdateServiceDeps {
|
||||
updateLauncher: (
|
||||
launcherPath?: string,
|
||||
channel?: UpdateChannel,
|
||||
release?: GitHubRelease | null,
|
||||
) => 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;
|
||||
quitAndInstall: () => void | Promise<void>;
|
||||
notifyUpdateAvailable: (version: string) => Promise<void>;
|
||||
log: (message: string) => void;
|
||||
setTimeout?: (callback: () => void, delayMs: number) => unknown;
|
||||
@@ -96,7 +97,7 @@ function summarizeError(error: unknown): string {
|
||||
}
|
||||
|
||||
export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
let inFlight: Promise<UpdateCheckResult> | null = null;
|
||||
const inFlightBySource = new Map<UpdateCheckSource, Promise<UpdateCheckResult>>();
|
||||
|
||||
async function runCheck(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||
const now = deps.now();
|
||||
@@ -157,17 +158,24 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
}
|
||||
|
||||
let appUpdateApplied = false;
|
||||
if (appUpdate.available && appUpdate.canUpdate !== false) {
|
||||
await deps.downloadAppUpdate();
|
||||
appUpdateApplied = true;
|
||||
}
|
||||
const launcherResult = await deps.updateLauncher(request.launcherPath, channel);
|
||||
const launcherResult = await deps.updateLauncher(request.launcherPath, channel, release);
|
||||
if (launcherResult.status === 'protected' && launcherResult.command) {
|
||||
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
|
||||
}
|
||||
|
||||
const launcherUpdateApplied = launcherResult.status === 'updated';
|
||||
if (!appUpdateApplied && !launcherUpdateApplied) {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
}
|
||||
|
||||
const restartChoice = await deps.showRestartDialog();
|
||||
if (restartChoice === 'restart') {
|
||||
deps.quitAndInstall();
|
||||
await deps.quitAndInstall();
|
||||
}
|
||||
return { status: 'updated', version: latest.version };
|
||||
} catch (error) {
|
||||
@@ -183,11 +191,13 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
|
||||
return {
|
||||
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||
const inFlight = inFlightBySource.get(request.source);
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = runCheck(request).finally(() => {
|
||||
inFlight = null;
|
||||
const nextInFlight = runCheck(request).finally(() => {
|
||||
inFlightBySource.delete(request.source);
|
||||
});
|
||||
return inFlight;
|
||||
inFlightBySource.set(request.source, nextInFlight);
|
||||
return nextInFlight;
|
||||
},
|
||||
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
|
||||
const setTimeoutFn = deps.setTimeout ?? setTimeout;
|
||||
|
||||
@@ -119,9 +119,9 @@ test('yomitan opener uses loaded extension from app state without calling loader
|
||||
assert.equal(forwardedExtension, appStateExtension);
|
||||
});
|
||||
|
||||
test('yomitan opener warns instead of starting a settings-triggered load when extension is not ready', async () => {
|
||||
test('yomitan opener lazy-loads extension when app state is empty and no load is in flight', async () => {
|
||||
let ensureCalled = false;
|
||||
const logs: string[] = [];
|
||||
let forwardedExtension: { id: string } | null = null;
|
||||
const openSettings = createOpenYomitanSettingsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
ensureCalled = true;
|
||||
@@ -129,19 +129,19 @@ test('yomitan opener warns instead of starting a settings-triggered load when ex
|
||||
},
|
||||
getYomitanExtension: () => null,
|
||||
getYomitanExtensionLoadInFlight: () => null,
|
||||
openYomitanSettingsWindow: () => {
|
||||
throw new Error('should not open before extension is ready');
|
||||
openYomitanSettingsWindow: ({ yomitanExt }) => {
|
||||
forwardedExtension = yomitanExt as { id: string };
|
||||
},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
logWarn: (message) => logs.push(message),
|
||||
logError: () => logs.push('error'),
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
openSettings();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(ensureCalled, false);
|
||||
assert.deepEqual(logs, ['Unable to open Yomitan settings: extension is not loaded yet.']);
|
||||
assert.equal(ensureCalled, true);
|
||||
assert.deepEqual(forwardedExtension, { id: 'ext' });
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export function createOpenYomitanSettingsHandler(deps: {
|
||||
return (): void => {
|
||||
void (async () => {
|
||||
if (deps.getYomitanExtension) {
|
||||
const loadedExtension = deps.getYomitanExtension();
|
||||
let loadedExtension = deps.getYomitanExtension();
|
||||
if (!loadedExtension) {
|
||||
if (deps.getYomitanExtensionLoadInFlight?.()) {
|
||||
deps.logWarn(
|
||||
@@ -30,8 +30,11 @@ export function createOpenYomitanSettingsHandler(deps: {
|
||||
);
|
||||
return;
|
||||
}
|
||||
deps.logWarn('Unable to open Yomitan settings: extension is not loaded yet.');
|
||||
return;
|
||||
loadedExtension = await deps.ensureYomitanExtensionLoaded();
|
||||
if (!loadedExtension) {
|
||||
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? null;
|
||||
|
||||
Reference in New Issue
Block a user