feat(macos): configuration window + curl-backed macOS updater (#71)

This commit is contained in:
2026-05-17 02:23:44 -07:00
committed by GitHub
parent 6ca5cede3e
commit e84674e3b5
100 changed files with 13890 additions and 235 deletions
+1 -2
View File
@@ -84,8 +84,7 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
: {}),
...(deps.getYomitanExtensionLoadInFlight
? {
getYomitanExtensionLoadInFlight: () =>
deps.getYomitanExtensionLoadInFlight?.() ?? null,
getYomitanExtensionLoadInFlight: () => deps.getYomitanExtensionLoadInFlight?.() ?? null,
}
: {}),
openYomitanSettingsWindow: (params: {
@@ -70,6 +70,7 @@ test('build cli command context deps maps handlers and values', () => {
calls.push('run-youtube-playback');
},
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('config-settings'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
printHelp: () => calls.push('help'),
@@ -44,6 +44,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -99,6 +100,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
runUpdateCommand: deps.runUpdateCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
@@ -74,6 +74,7 @@ test('cli command context factory composes main deps and context handlers', () =
runUpdateCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -100,6 +100,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
calls.push('run-youtube-playback');
},
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettingsWindow: () => calls.push('open-config-settings'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('open-runtime-options'),
printHelp: () => calls.push('help'),
@@ -129,6 +130,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
deps.initializeOverlay();
deps.openFirstRunSetup(true);
deps.setVisibleOverlay(true);
deps.openConfigSettingsWindow();
deps.printHelp();
await deps.runUpdateCommand({ update: true } as never, 'initial');
@@ -137,6 +139,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
'init-overlay',
'open-setup:force',
'set-visible:true',
'open-config-settings',
'help',
'run-update',
]);
@@ -57,6 +57,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -127,6 +128,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
deps.runUpdateCommand(args, source),
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
openYomitanSettings: () => deps.openYomitanSettings(),
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
printHelp: () => deps.printHelp(),
@@ -56,6 +56,7 @@ function createDeps() {
runUpdateCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
+2
View File
@@ -49,6 +49,7 @@ export type CliCommandContextFactoryDeps = {
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -126,6 +127,7 @@ export function createCliCommandContext(
runUpdateCommand: deps.runUpdateCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
printHelp: deps.printHelp,
@@ -47,10 +47,7 @@ export function getUserPath(options: CommonOptions & WindowsPathOptions): string
return options.getUserPath?.() ?? envOf(options).Path ?? envOf(options).PATH ?? '';
}
async function setWindowsUserPath(
options: CommonOptions & WindowsPathOptions,
nextPath: string,
) {
async function setWindowsUserPath(options: CommonOptions & WindowsPathOptions, nextPath: string) {
if (options.setUserPath) {
await options.setUserPath(nextPath);
return;
@@ -96,6 +93,7 @@ export async function appendWindowsUserPathDir(
}
export function defaultBunRepairPath(options: CommonOptions & WindowsPathOptions): string {
const userProfile = options.userProfile ?? envOf(options).USERPROFILE ?? options.homeDir ?? os.homedir();
const userProfile =
options.userProfile ?? envOf(options).USERPROFILE ?? options.homeDir ?? os.homedir();
return path.win32.join(userProfile, '.bun', 'bin');
}
@@ -50,6 +50,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
runUpdateCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {},
printHelp: () => {},
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isConfigSettingsPatch } from './config-settings-ipc';
import type { ConfigSettingsField } from '../../types/settings';
const fields: ConfigSettingsField[] = [
{
id: 'mpv.launchMode',
label: 'Launch mode',
description: 'Launch mode setting.',
configPath: 'mpv.launchMode',
category: 'playback-sources',
section: 'mpv launcher',
control: 'select',
defaultValue: 'windowed',
restartBehavior: 'restart',
},
];
test('isConfigSettingsPatch rejects set operations without a value property', () => {
assert.equal(
isConfigSettingsPatch(
{
operations: [{ op: 'set', path: 'mpv.launchMode' }],
},
fields,
),
false,
);
});
test('isConfigSettingsPatch accepts set operations with an explicit value', () => {
assert.equal(
isConfigSettingsPatch(
{
operations: [{ op: 'set', path: 'mpv.launchMode', value: 'fullscreen' }],
},
fields,
),
true,
);
});
test('isConfigSettingsPatch accepts reset operations without a value', () => {
assert.equal(
isConfigSettingsPatch(
{
operations: [{ op: 'reset', path: 'mpv.launchMode' }],
},
fields,
),
true,
);
});
test('isConfigSettingsPatch rejects unknown config paths', () => {
assert.equal(
isConfigSettingsPatch(
{
operations: [{ op: 'reset', path: 'unknown.path' }],
},
fields,
),
false,
);
});
+30
View File
@@ -0,0 +1,30 @@
import type { ConfigSettingsField, ConfigSettingsPatch } from '../../types/settings';
export function isConfigSettingsPatch(
value: unknown,
fields: readonly ConfigSettingsField[],
): value is ConfigSettingsPatch {
if (!value || typeof value !== 'object') {
return false;
}
const operations = (value as { operations?: unknown }).operations;
return (
Array.isArray(operations) &&
operations.every((operation) => {
if (!operation || typeof operation !== 'object') {
return false;
}
const candidate = operation as { op?: unknown; path?: unknown; value?: unknown };
const knownPath =
typeof candidate.path === 'string' &&
fields.some((field) => field.configPath === candidate.path);
if (!knownPath) {
return false;
}
if (candidate.op === 'set') {
return 'value' in candidate;
}
return candidate.op === 'reset';
})
);
}
+166
View File
@@ -0,0 +1,166 @@
import fs from 'node:fs';
import path from 'node:path';
import { buildConfigSettingsSnapshot } from '../../config/settings/jsonc-edit';
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
import type {
ConfigSettingsField,
ConfigSettingsSaveResult,
ConfigSettingsSnapshot,
} from '../../types/settings';
import type { ReloadConfigStrictResult } from '../../config';
import {
classifyConfigHotReloadDiff,
type ConfigHotReloadDiff,
} from '../../core/services/config-hot-reload';
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
import {
createOpenConfigSettingsWindowHandler,
type ConfigSettingsWindowLike,
} from './config-settings-window';
import { isConfigSettingsPatch } from './config-settings-ipc';
export interface ConfigSettingsIpcMainLike {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
}
export interface ConfigSettingsIpcChannels {
getConfigSettingsSnapshot: string;
saveConfigSettingsPatch: string;
openConfigSettingsFile: string;
openConfigSettingsWindow: string;
}
export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowLike> {
fields: ConfigSettingsField[];
getConfigPath(): string;
getRawConfig(): RawConfig;
getConfig(): ResolvedConfig;
getWarnings(): ConfigValidationWarning[];
reloadConfigStrict(): ReloadConfigStrictResult;
applyHotReload(diff: ConfigHotReloadDiff, config: ResolvedConfig): void;
getSettingsWindow(): TWindow | null;
setSettingsWindow(window: TWindow | null): void;
createSettingsWindow(): TWindow;
settingsHtmlPath: string;
openPath(path: string): Promise<string>;
ipcMain: ConfigSettingsIpcMainLike;
ipcChannels: ConfigSettingsIpcChannels;
log?: (message: string) => void;
}
export function writeTextFileAtomically(targetPath: string, content: string): void {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
const tempPath = path.join(
path.dirname(targetPath),
`.${path.basename(targetPath)}.${process.pid}.${Date.now()}.tmp`,
);
try {
fs.writeFileSync(tempPath, content, 'utf-8');
fs.renameSync(tempPath, targetPath);
} catch (error) {
try {
fs.rmSync(tempPath, { force: true });
} catch {
// Best effort cleanup after a failed atomic write.
}
throw error;
}
}
function getRestartRequiredSettingsSections(
fields: readonly ConfigSettingsField[],
restartRequiredFields: string[],
): string[] {
const sections = new Set<string>();
for (const field of fields) {
if (
restartRequiredFields.some(
(restartField) =>
field.configPath === restartField ||
field.configPath.startsWith(`${restartField}.`) ||
restartField.startsWith(`${field.configPath}.`),
)
) {
sections.add(field.section);
}
}
return [...sections].sort();
}
export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindowLike>(
deps: ConfigSettingsRuntimeDeps<TWindow>,
) {
function getSnapshot(): ConfigSettingsSnapshot {
return buildConfigSettingsSnapshot({
configPath: deps.getConfigPath(),
rawConfig: deps.getRawConfig(),
resolvedConfig: deps.getConfig(),
warnings: deps.getWarnings(),
fields: deps.fields,
});
}
const savePatch = createSaveConfigSettingsPatchHandler({
getConfigPath: () => deps.getConfigPath(),
getCurrentConfig: () => deps.getConfig(),
getWarnings: () => deps.getWarnings(),
getSnapshot,
fileExists: (targetPath) => fs.existsSync(targetPath),
readText: (targetPath) => fs.readFileSync(targetPath, 'utf-8'),
writeTextAtomically: (targetPath, content) => writeTextFileAtomically(targetPath, content),
deleteFile: (targetPath) => fs.rmSync(targetPath, { force: true }),
reloadConfigStrict: () => deps.reloadConfigStrict(),
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
applyHotReload: (diff, config) => deps.applyHotReload(diff, config),
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
});
function ensureConfigFileExists(): string {
const configPath = deps.getConfigPath();
if (!fs.existsSync(configPath)) {
writeTextFileAtomically(configPath, '{}\n');
}
return configPath;
}
const openWindow = createOpenConfigSettingsWindowHandler({
getSettingsWindow: deps.getSettingsWindow,
setSettingsWindow: deps.setSettingsWindow,
createSettingsWindow: deps.createSettingsWindow,
settingsHtmlPath: deps.settingsHtmlPath,
log: deps.log,
});
function invalidPatchResult(): ConfigSettingsSaveResult {
return {
ok: false,
warnings: [],
error: 'Invalid config settings patch.',
hotReloadFields: [],
restartRequiredFields: [],
restartRequiredSections: [],
};
}
function registerHandlers(): void {
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsSnapshot, () => getSnapshot());
deps.ipcMain.handle(deps.ipcChannels.saveConfigSettingsPatch, (_event, patch: unknown) => {
if (!isConfigSettingsPatch(patch, deps.fields)) {
return invalidPatchResult();
}
return savePatch(patch);
});
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsFile, async () => {
const openError = await deps.openPath(ensureConfigFileExists());
return openError.length === 0;
});
deps.ipcMain.handle(deps.ipcChannels.openConfigSettingsWindow, () => openWindow());
}
return {
getSnapshot,
savePatch,
openWindow,
registerHandlers,
};
}
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_CONFIG, type ReloadConfigStrictResult } from '../../config';
import type { ResolvedConfig } from '../../types/config';
import type { ConfigSettingsSnapshot } from '../../types/settings';
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
function snapshot(): ConfigSettingsSnapshot {
return {
configPath: '/tmp/config.jsonc',
fields: [],
values: {},
warnings: [],
};
}
test('config settings save applies hot-reloadable diff live', () => {
const calls: string[] = [];
const previous = DEFAULT_CONFIG;
const next: ResolvedConfig = {
...DEFAULT_CONFIG,
subtitleStyle: {
...DEFAULT_CONFIG.subtitleStyle,
autoPauseVideoOnHover: false,
},
};
let written = '';
const save = createSaveConfigSettingsPatchHandler({
getConfigPath: () => '/tmp/config.jsonc',
getCurrentConfig: () => previous,
getWarnings: () => [],
getSnapshot: () => snapshot(),
fileExists: () => true,
readText: () => '{}',
writeTextAtomically: (_path, content) => {
written = content;
calls.push('write');
},
reloadConfigStrict: (): ReloadConfigStrictResult => ({
ok: true,
config: next,
warnings: [],
path: '/tmp/config.jsonc',
}),
classifyDiff: () => ({
hotReloadFields: ['subtitleStyle'],
restartRequiredFields: [],
}),
applyHotReload: (diff) => calls.push(`hot:${diff.hotReloadFields.join(',')}`),
getRestartRequiredSections: () => [],
});
const result = save({
operations: [
{
op: 'set',
path: 'subtitleStyle.autoPauseVideoOnHover',
value: false,
},
],
});
assert.equal(result.ok, true);
assert.match(written, /autoPauseVideoOnHover/);
assert.deepEqual(calls, ['write', 'hot:subtitleStyle']);
assert.deepEqual(result.hotReloadFields, ['subtitleStyle']);
assert.deepEqual(result.restartRequiredFields, []);
});
test('config settings save returns restart-required sections without applying hot reload', () => {
const calls: string[] = [];
const previous = DEFAULT_CONFIG;
const next: ResolvedConfig = {
...DEFAULT_CONFIG,
mpv: {
...DEFAULT_CONFIG.mpv,
launchMode: 'fullscreen',
},
};
const save = createSaveConfigSettingsPatchHandler({
getConfigPath: () => '/tmp/config.jsonc',
getCurrentConfig: () => previous,
getWarnings: () => [],
getSnapshot: () => snapshot(),
fileExists: () => true,
readText: () => '{}',
writeTextAtomically: () => calls.push('write'),
reloadConfigStrict: (): ReloadConfigStrictResult => ({
ok: true,
config: next,
warnings: [],
path: '/tmp/config.jsonc',
}),
classifyDiff: () => ({
hotReloadFields: [],
restartRequiredFields: ['mpv'],
}),
applyHotReload: () => calls.push('hot'),
getRestartRequiredSections: () => ['mpv launcher'],
});
const result = save({
operations: [{ op: 'set', path: 'mpv.launchMode', value: 'fullscreen' }],
});
assert.equal(result.ok, true);
assert.deepEqual(calls, ['write']);
assert.deepEqual(result.hotReloadFields, []);
assert.deepEqual(result.restartRequiredFields, ['mpv']);
assert.deepEqual(result.restartRequiredSections, ['mpv launcher']);
});
test('config settings save restores previous file content when strict reload fails', () => {
const writes: string[] = [];
const save = createSaveConfigSettingsPatchHandler({
getConfigPath: () => '/tmp/config.jsonc',
getCurrentConfig: () => DEFAULT_CONFIG,
getWarnings: () => [],
getSnapshot: () => snapshot(),
fileExists: () => true,
readText: () => '{"mpv":{"launchMode":"normal"}}\n',
writeTextAtomically: (_path, content) => {
writes.push(content);
},
reloadConfigStrict: (): ReloadConfigStrictResult => ({
ok: false,
error: 'invalid config',
path: '/tmp/config.jsonc',
}),
classifyDiff: () => {
throw new Error('Should not classify invalid config.');
},
applyHotReload: () => {
throw new Error('Should not hot reload invalid config.');
},
getRestartRequiredSections: () => [],
});
const result = save({
operations: [{ op: 'set', path: 'mpv.launchMode', value: 'fullscreen' }],
});
assert.equal(result.ok, false);
assert.equal(result.error, 'invalid config');
assert.equal(writes.length, 2);
assert.match(writes[0] ?? '', /fullscreen/);
assert.equal(writes[1], '{"mpv":{"launchMode":"normal"}}\n');
});
+98
View File
@@ -0,0 +1,98 @@
import type { ReloadConfigStrictResult } from '../../config';
import { applyConfigSettingsPatchToContent } from '../../config/settings/jsonc-edit';
import type { ConfigValidationWarning, ResolvedConfig } from '../../types/config';
import type {
ConfigSettingsPatch,
ConfigSettingsSaveResult,
ConfigSettingsSnapshot,
} from '../../types/settings';
export interface ConfigSettingsHotReloadDiff {
hotReloadFields: string[];
restartRequiredFields: string[];
}
export interface ConfigSettingsSaveDeps {
getConfigPath(): string;
getCurrentConfig(): ResolvedConfig;
getWarnings(): ConfigValidationWarning[];
getSnapshot(): ConfigSettingsSnapshot;
fileExists(path: string): boolean;
readText(path: string): string;
writeTextAtomically(path: string, content: string): void;
deleteFile?(path: string): void;
reloadConfigStrict(): ReloadConfigStrictResult;
classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff;
applyHotReload(diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig): void;
getRestartRequiredSections(restartRequiredFields: string[]): string[];
}
export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDeps) {
return (patch: ConfigSettingsPatch): ConfigSettingsSaveResult => {
if (patch.operations.length === 0) {
return {
ok: true,
snapshot: deps.getSnapshot(),
hotReloadFields: [],
restartRequiredFields: [],
restartRequiredSections: [],
};
}
const configPath = deps.getConfigPath();
const previousConfig = deps.getCurrentConfig();
const previousWarnings = deps.getWarnings();
const hadExistingConfig = deps.fileExists(configPath);
const content = hadExistingConfig ? deps.readText(configPath) : '{}\n';
const candidate = applyConfigSettingsPatchToContent({
content,
operations: patch.operations,
previousWarnings,
});
if (!candidate.ok) {
return {
ok: false,
warnings: candidate.warnings,
error: candidate.error,
hotReloadFields: [],
restartRequiredFields: [],
restartRequiredSections: [],
};
}
deps.writeTextAtomically(configPath, candidate.content);
const reloadResult = deps.reloadConfigStrict();
if (!reloadResult.ok) {
if (hadExistingConfig) {
deps.writeTextAtomically(configPath, content);
} else if (deps.deleteFile) {
deps.deleteFile(configPath);
} else {
deps.writeTextAtomically(configPath, content);
}
return {
ok: false,
warnings: [],
error: reloadResult.error,
hotReloadFields: [],
restartRequiredFields: [],
restartRequiredSections: [],
};
}
const diff = deps.classifyDiff(previousConfig, reloadResult.config);
if (diff.hotReloadFields.length > 0) {
deps.applyHotReload(diff, reloadResult.config);
}
return {
ok: true,
snapshot: deps.getSnapshot(),
warnings: reloadResult.warnings,
hotReloadFields: diff.hotReloadFields,
restartRequiredFields: diff.restartRequiredFields,
restartRequiredSections: deps.getRestartRequiredSections(diff.restartRequiredFields),
};
};
}
@@ -0,0 +1,83 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOpenConfigSettingsWindowHandler } from './config-settings-window';
test('createOpenConfigSettingsWindowHandler focuses existing settings window', () => {
const calls: string[] = [];
const existing = {
isDestroyed: () => false,
focus: () => calls.push('focus'),
loadFile: () => calls.push('load'),
on: () => {},
};
const open = createOpenConfigSettingsWindowHandler({
getSettingsWindow: () => existing,
setSettingsWindow: () => calls.push('set'),
createSettingsWindow: () => {
throw new Error('Should not create a second window.');
},
settingsHtmlPath: '/tmp/settings.html',
});
assert.equal(open(), true);
assert.deepEqual(calls, ['focus']);
});
test('createOpenConfigSettingsWindowHandler creates window and clears closed state', () => {
const calls: string[] = [];
const handlers: { closed?: () => void } = {};
const created = {
isDestroyed: () => false,
focus: () => calls.push('focus'),
loadFile: (path: string) => calls.push(`load:${path}`),
on: (event: string, handler: () => void) => {
if (event === 'closed') handlers.closed = handler;
},
};
const open = createOpenConfigSettingsWindowHandler({
getSettingsWindow: () => null,
setSettingsWindow: (window) => calls.push(window ? 'set:window' : 'set:null'),
createSettingsWindow: () => created,
settingsHtmlPath: '/tmp/settings.html',
});
assert.equal(open(), true);
assert.deepEqual(calls, ['load:/tmp/settings.html', 'set:window', 'focus']);
assert.ok(handlers.closed);
handlers.closed();
assert.equal(calls.at(-1), 'set:null');
});
test('createOpenConfigSettingsWindowHandler clears failed load window state', async () => {
const calls: string[] = [];
const created = {
isDestroyed: () => false,
focus: () => calls.push('focus'),
loadFile: (path: string) => {
calls.push(`load:${path}`);
return Promise.reject(new Error('missing settings html'));
},
on: () => {},
destroy: () => calls.push('destroy'),
};
const open = createOpenConfigSettingsWindowHandler({
getSettingsWindow: () => null,
setSettingsWindow: (window) => calls.push(window ? 'set:window' : 'set:null'),
createSettingsWindow: () => created,
settingsHtmlPath: '/tmp/missing-settings.html',
});
assert.equal(open(), true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
'load:/tmp/missing-settings.html',
'set:window',
'focus',
'set:null',
'destroy',
]);
});
@@ -0,0 +1,41 @@
export interface ConfigSettingsWindowLike {
isDestroyed(): boolean;
focus(): void;
loadFile(path: string): unknown;
on(event: 'closed', handler: () => void): unknown;
destroy?(): unknown;
}
export interface OpenConfigSettingsWindowDeps<TWindow extends ConfigSettingsWindowLike> {
getSettingsWindow(): TWindow | null;
setSettingsWindow(window: TWindow | null): void;
createSettingsWindow(): TWindow;
settingsHtmlPath: string;
log?: (message: string) => void;
}
export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSettingsWindowLike>(
deps: OpenConfigSettingsWindowDeps<TWindow>,
): () => boolean {
return () => {
const existing = deps.getSettingsWindow();
if (existing && !existing.isDestroyed()) {
existing.focus();
return true;
}
const window = deps.createSettingsWindow();
void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
deps.log?.(`Failed to load configuration settings window: ${message}`);
deps.setSettingsWindow(null);
window.destroy?.();
});
deps.setSettingsWindow(window);
window.on('closed', () => {
deps.setSettingsWindow(null);
});
window.focus();
return true;
};
}
@@ -30,6 +30,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -120,6 +121,7 @@ function createCommandLineLauncherSnapshot(
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, configSettings: true })), false);
assert.equal(
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
false,
@@ -72,6 +72,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.togglePrimarySubtitleBar ||
args.launchMpv ||
args.settings ||
args.configSettings ||
args.show ||
args.hide ||
args.showVisibleOverlay ||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
createCreateAnilistSetupWindowHandler,
createCreateConfigSettingsWindowHandler,
createCreateFirstRunSetupWindowHandler,
createCreateJellyfinSetupWindowHandler,
} from './setup-window-factory';
@@ -77,3 +78,31 @@ test('createCreateAnilistSetupWindowHandler builds anilist setup window', () =>
},
});
});
test('createCreateConfigSettingsWindowHandler builds configuration settings window', () => {
let options: Electron.BrowserWindowConstructorOptions | null = null;
const createSettingsWindow = createCreateConfigSettingsWindowHandler({
preloadPath: '/tmp/preload-settings.js',
createBrowserWindow: (nextOptions) => {
options = nextOptions;
return { id: 'config-settings' } as never;
},
});
assert.deepEqual(createSettingsWindow(), { id: 'config-settings' });
assert.deepEqual(options, {
width: 1040,
height: 760,
title: 'SubMiner Configuration',
show: true,
autoHideMenuBar: true,
resizable: true,
backgroundColor: '#24273a',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
preload: '/tmp/preload-settings.js',
},
});
});
+21
View File
@@ -5,6 +5,9 @@ interface SetupWindowConfig {
resizable?: boolean;
minimizable?: boolean;
maximizable?: boolean;
preloadPath?: string;
sandbox?: boolean;
backgroundColor?: string;
}
function createSetupWindowHandler<TWindow>(
@@ -21,9 +24,12 @@ function createSetupWindowHandler<TWindow>(
...(config.resizable === undefined ? {} : { resizable: config.resizable }),
...(config.minimizable === undefined ? {} : { minimizable: config.minimizable }),
...(config.maximizable === undefined ? {} : { maximizable: config.maximizable }),
...(config.backgroundColor === undefined ? {} : { backgroundColor: config.backgroundColor }),
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
...(config.sandbox === undefined ? {} : { sandbox: config.sandbox }),
...(config.preloadPath ? { preload: config.preloadPath } : {}),
},
});
}
@@ -60,3 +66,18 @@ export function createCreateAnilistSetupWindowHandler<TWindow>(deps: {
title: 'Anilist Setup',
});
}
export function createCreateConfigSettingsWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
preloadPath: string;
}) {
return createSetupWindowHandler(deps, {
width: 1040,
height: 760,
title: 'SubMiner Configuration',
resizable: true,
preloadPath: deps.preloadPath,
sandbox: false,
backgroundColor: '#24273a',
});
}
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseArgs } from '../../cli/args';
import {
getStartupModeFlags,
shouldRefreshAnilistOnConfigReload,
shouldStartAutomaticUpdateChecks,
} from './startup-mode-flags';
test('config settings startup uses minimal startup and skips background integrations', () => {
const args = parseArgs(['--config']);
const flags = getStartupModeFlags(args);
assert.equal(flags.shouldUseMinimalStartup, true);
assert.equal(flags.shouldSkipHeavyStartup, true);
assert.equal(shouldRefreshAnilistOnConfigReload(args), false);
assert.equal(shouldStartAutomaticUpdateChecks(args), false);
});
test('normal startup still allows background integrations', () => {
const flags = getStartupModeFlags(null);
assert.equal(flags.shouldUseMinimalStartup, false);
assert.equal(flags.shouldSkipHeavyStartup, false);
assert.equal(shouldRefreshAnilistOnConfigReload(null), true);
assert.equal(shouldStartAutomaticUpdateChecks(null), true);
});
+40
View File
@@ -0,0 +1,40 @@
import type { CliArgs } from '../../cli/args';
import {
isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
shouldRunSettingsOnlyStartup,
} from '../../cli/args';
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
shouldUseMinimalStartup: boolean;
shouldSkipHeavyStartup: boolean;
} {
return {
shouldUseMinimalStartup: Boolean(
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
initialArgs?.configSettings ||
initialArgs?.update ||
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
),
shouldSkipHeavyStartup: Boolean(
initialArgs &&
(shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.configSettings ||
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.update ||
initialArgs.setup),
),
};
}
export function shouldRefreshAnilistOnConfigReload(
initialArgs: CliArgs | null | undefined,
): boolean {
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings));
}
export function shouldStartAutomaticUpdateChecks(initialArgs: CliArgs | null | undefined): boolean {
return !(initialArgs && (isHeadlessInitialCommand(initialArgs) || initialArgs.configSettings));
}
@@ -48,6 +48,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
handlers.openRuntimeOptions();
handlers.openConfigSettings();
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery();
handlers.openAnilistSetup();
@@ -68,6 +69,7 @@ test('build tray template handler wires actions and init guards', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openConfigSettingsWindow: () => calls.push('configuration'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
isJellyfinDiscoveryActive: () => false,
@@ -90,6 +92,7 @@ test('build tray template handler wires actions and init guards', () => {
'setup',
'yomitan',
'runtime-options',
'configuration',
'jellyfin',
'jellyfin-discovery',
'anilist',
+5
View File
@@ -37,6 +37,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openConfigSettings: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
@@ -55,6 +56,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openConfigSettingsWindow: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean;
@@ -92,6 +94,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
}
deps.openRuntimeOptionsPalette();
},
openConfigSettings: () => {
deps.openConfigSettingsWindow();
},
openJellyfinSetup: () => {
deps.openJellyfinSetupWindow();
},
+2
View File
@@ -32,6 +32,7 @@ test('tray main deps builders return mapped handlers', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openConfigSettingsWindow: () => calls.push('configuration'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
isJellyfinDiscoveryActive: () => false,
@@ -53,6 +54,7 @@ test('tray main deps builders return mapped handlers', () => {
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('open-yomitan'),
openRuntimeOptions: () => calls.push('open-runtime-options'),
openConfigSettings: () => calls.push('open-configuration'),
openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false,
+3
View File
@@ -36,6 +36,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openConfigSettings: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
@@ -54,6 +55,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openConfigSettingsWindow: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean;
@@ -74,6 +76,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
isJellyfinConfigured: deps.isJellyfinConfigured,
isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive,
@@ -32,6 +32,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {},
openRuntimeOptionsPalette: () => {},
openConfigSettingsWindow: () => {},
openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
isJellyfinDiscoveryActive: () => false,
+9 -5
View File
@@ -38,6 +38,7 @@ test('tray menu template contains expected entries and handlers', () => {
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptions: () => calls.push('runtime'),
openConfigSettings: () => calls.push('configuration'),
openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false,
@@ -47,7 +48,7 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 12);
assert.equal(template.length, 13);
assert.equal(
template.some((entry) => entry.label === 'Open Overlay'),
false,
@@ -60,10 +61,10 @@ test('tray menu template contains expected entries and handlers', () => {
template[0]!.click?.();
assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.();
assert.equal(template[9]!.label, 'Check for Updates');
template[9]!.click?.();
template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[11]!.click?.();
assert.equal(template[10]!.label, 'Check for Updates');
template[10]!.click?.();
template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[12]!.click?.();
assert.deepEqual(calls, [
'jellyfin-discovery',
'help',
@@ -85,6 +86,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openConfigSettings: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
jellyfinDiscoveryActive: false,
@@ -112,6 +114,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openConfigSettings: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
jellyfinDiscoveryActive: false,
@@ -137,6 +140,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openConfigSettings: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: true,
+5
View File
@@ -39,6 +39,7 @@ export type TrayMenuActionHandlers = {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openConfigSettings: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
@@ -92,6 +93,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Open Runtime Options',
click: handlers.openRuntimeOptions,
},
{
label: 'Open Configuration',
click: handlers.openConfigSettings,
},
{
label: 'Configure Jellyfin',
click: handlers.openJellyfinSetup,
@@ -162,6 +162,46 @@ test('app updater skips native downloads when native updater is unsupported', as
assert.deepEqual(logged, ['Skipping app update download because native updater is unsupported.']);
});
test('app updater installs a custom HTTP executor before native checks', async () => {
const httpExecutor = { request: async () => null };
let executorDuringCheck: unknown;
let differentialDownloadDuringCheck: unknown;
const updater: ElectronAutoUpdaterLike & {
httpExecutor?: unknown;
disableDifferentialDownload?: boolean;
} = {
autoDownload: true,
allowPrerelease: false,
allowDowngrade: true,
logger: null,
checkForUpdates: async () => {
executorDuringCheck = updater.httpExecutor;
differentialDownloadDuringCheck = updater.disableDifferentialDownload;
return {
updateInfo: {
version: '0.15.0',
},
};
},
downloadUpdate: async () => [],
quitAndInstall: () => {},
};
const appUpdater = createElectronAppUpdater({
currentVersion: '0.14.0',
isPackaged: true,
updater,
log: () => {},
configureHttpExecutor: () => httpExecutor,
disableDifferentialDownload: true,
});
const result = await appUpdater.checkForUpdates('stable');
assert.equal(result.available, true);
assert.equal(executorDuringCheck, httpExecutor);
assert.equal(differentialDownloadDuringCheck, true);
});
test('resolveMacAppBundlePath resolves packaged macOS executable path', () => {
assert.equal(
resolveMacAppBundlePath('/Applications/SubMiner.app/Contents/MacOS/SubMiner'),
@@ -185,6 +225,25 @@ test('mac native updater is unsupported for ad-hoc signed app bundles', async ()
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
});
test('mac native updater is unsupported outside Applications folders before signature probing', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'darwin',
isPackaged: true,
execPath: '/Users/tester/build/SubMiner.app/Contents/MacOS/SubMiner',
homeDir: '/Users/tester',
readCodeSignature: () => {
throw new Error('signature should not be read');
},
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native macOS updater because the app is not installed in an Applications folder.',
]);
});
test('mac native updater supports Developer ID signed packaged app bundles', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
+43
View File
@@ -1,5 +1,7 @@
import { realpathSync } from 'node:fs';
import { execFile } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { autoUpdater as electronAutoUpdater } from 'electron-updater';
import type { UpdateChannel } from '../../../types/config';
@@ -34,11 +36,16 @@ export interface ElectronAutoUpdaterLike {
} | null>;
downloadUpdate: () => Promise<unknown>;
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
disableDifferentialDownload?: boolean;
}
const updaterErrorListeners = new WeakMap<object, (error: unknown) => void>();
const execFileAsync = promisify(execFile);
type ElectronAutoUpdaterWithHttpExecutor = ElectronAutoUpdaterLike & {
httpExecutor?: unknown;
};
export function resolveMacAppBundlePath(execPath: string): string | null {
const marker = '.app/Contents/MacOS/';
const markerIndex = execPath.indexOf(marker);
@@ -65,6 +72,25 @@ function realpathOrOriginal(filePath: string): string {
}
}
function isSameOrInsideDirectory(parentPath: string, candidatePath: string): boolean {
const relative = path.relative(parentPath, candidatePath);
return (
relative === '' ||
(relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative))
);
}
export function isMacApplicationsFolderBundle(
appBundlePath: string,
homeDir: string = os.homedir(),
): boolean {
const resolvedBundlePath = path.resolve(appBundlePath);
return (
isSameOrInsideDirectory('/Applications', resolvedBundlePath) ||
isSameOrInsideDirectory(path.join(homeDir, 'Applications'), resolvedBundlePath)
);
}
export function isKnownLinuxPackageManagedAppImage(appImagePath: string): boolean {
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
}
@@ -74,6 +100,7 @@ export async function isNativeUpdaterSupported(options: {
isPackaged: boolean;
execPath: string;
env?: NodeJS.ProcessEnv;
homeDir?: string;
readCodeSignature?: (appBundlePath: string) => string | null | Promise<string | null>;
log?: (message: string) => void;
}): Promise<boolean> {
@@ -100,6 +127,13 @@ export async function isNativeUpdaterSupported(options: {
return false;
}
if (!isMacApplicationsFolderBundle(appBundlePath, options.homeDir)) {
options.log?.(
'Skipping native macOS updater because the app is not installed in an Applications folder.',
);
return false;
}
const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
if (!signature) {
options.log?.(
@@ -157,6 +191,8 @@ export function createElectronAppUpdater(options: {
log: (message: string) => void;
getChannel?: () => UpdateChannel;
isNativeUpdaterSupported?: () => boolean | Promise<boolean>;
configureHttpExecutor?: () => unknown;
disableDifferentialDownload?: boolean;
}) {
const getChannel = options.getChannel ?? (() => 'stable' as const);
const updater = configureAutoUpdater(
@@ -164,6 +200,13 @@ export function createElectronAppUpdater(options: {
options.log,
getChannel(),
);
if (options.configureHttpExecutor) {
// electron-updater has no public executor hook; keep the macOS cURL override localized.
(updater as ElectronAutoUpdaterWithHttpExecutor).httpExecutor = options.configureHttpExecutor();
}
if (options.disableDifferentialDownload !== undefined) {
updater.disableDifferentialDownload = options.disableDifferentialDownload;
}
let nativeUpdaterSupported: Promise<boolean> | null = null;
async function getNativeUpdaterSupported(): Promise<boolean> {
@@ -0,0 +1,144 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { createCurlHttpExecutor, type CurlExecFile } from './curl-http-executor';
test('curl HTTP executor requests updater metadata without Electron networking', async () => {
const calls: Array<{ file: string; args: readonly string[] }> = [];
const execFile: CurlExecFile = (file, args, _options, callback) => {
calls.push({ file, args });
queueMicrotask(() => callback(null, 'metadata', ''));
return { kill: () => true };
};
const executor = createCurlHttpExecutor({ execFile, curlPath: '/usr/bin/curl' });
const result = await executor.request({
protocol: 'https:',
hostname: 'api.github.com',
path: '/repos/ksyasuda/SubMiner/releases',
headers: {
Accept: 'application/vnd.github+json',
'x-user-staging-id': 'abc',
},
timeout: 120_000,
});
assert.equal(result, 'metadata');
assert.equal(calls.length, 1);
assert.equal(calls[0]?.file, '/usr/bin/curl');
assert.deepEqual(calls[0]?.args, [
'--fail',
'--location',
'--silent',
'--show-error',
'--connect-timeout',
'30',
'--max-time',
'120',
'--header',
'Accept: application/vnd.github+json',
'--header',
'x-user-staging-id: abc',
'https://api.github.com/repos/ksyasuda/SubMiner/releases',
]);
});
test('curl HTTP executor downloads updater assets to the requested destination', async () => {
const calls: Array<{ args: readonly string[] }> = [];
const execFile: CurlExecFile = (_file, args, _options, callback) => {
calls.push({ args });
queueMicrotask(() => callback(null, Buffer.alloc(0), Buffer.alloc(0)));
return { kill: () => true };
};
const executor = createCurlHttpExecutor({
execFile,
curlPath: '/usr/bin/curl',
mkdir: async () => undefined,
});
await executor.download(
new URL('https://github.com/ksyasuda/SubMiner/releases/download/v1/app.zip'),
'/tmp/subminer/update.zip',
{
headers: { 'User-Agent': 'SubMiner updater' },
cancellationToken: {
createPromise: (callback) =>
new Promise((resolve, reject) => callback(resolve, reject, () => {})),
},
},
);
assert.deepEqual(calls[0]?.args, [
'--fail',
'--location',
'--silent',
'--show-error',
'--connect-timeout',
'30',
'--header',
'User-Agent: SubMiner updater',
'--output',
'/tmp/subminer/update.zip',
'https://github.com/ksyasuda/SubMiner/releases/download/v1/app.zip',
]);
});
test('curl HTTP executor verifies downloaded updater asset hashes', async () => {
const data = Buffer.from('zip payload');
const expectedSha512 = createHash('sha512').update(data).digest('base64');
const execFile: CurlExecFile = (_file, _args, _options, callback) => {
queueMicrotask(() => callback(null, Buffer.alloc(0), Buffer.alloc(0)));
return { kill: () => true };
};
const executor = createCurlHttpExecutor({
execFile,
curlPath: '/usr/bin/curl',
mkdir: async () => undefined,
readFile: async () => data,
});
await executor.download(new URL('https://example.test/update.zip'), '/tmp/subminer/update.zip', {
sha512: expectedSha512,
cancellationToken: {
createPromise: (callback) =>
new Promise((resolve, reject) => callback(resolve, reject, () => {})),
},
});
await assert.rejects(
() =>
executor.download(new URL('https://example.test/update.zip'), '/tmp/subminer/update.zip', {
sha512: 'bad',
cancellationToken: {
createPromise: (callback) =>
new Promise((resolve, reject) => callback(resolve, reject, () => {})),
},
}),
/sha512 mismatch/,
);
});
test('curl HTTP executor does not expose command arguments when stderr is empty', async () => {
const execFile: CurlExecFile = (_file, _args, _options, callback) => {
const error = new Error('--header Authorization: Bearer secret-token');
Object.assign(error, { code: 'ENOENT' });
queueMicrotask(() => callback(error, '', ''));
return { kill: () => true };
};
const executor = createCurlHttpExecutor({ execFile, curlPath: '/usr/bin/curl' });
await assert.rejects(
() =>
executor.request({
protocol: 'https:',
hostname: 'api.github.com',
path: '/repos/ksyasuda/SubMiner/releases',
}),
(error) => {
assert.ok(error instanceof Error);
assert.equal(error.message, 'curl failed (ENOENT)');
assert.doesNotMatch(error.message, /secret-token|Authorization/);
return true;
},
);
});
@@ -0,0 +1,212 @@
import { execFile as defaultExecFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { RequestOptions, OutgoingHttpHeaders } from 'node:http';
export type CurlExecFile = (
file: string,
args: readonly string[],
options: {
encoding: 'utf8' | 'buffer';
maxBuffer?: number;
timeout?: number;
},
callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void,
) => { kill: (signal?: NodeJS.Signals) => unknown };
type CancellationTokenLike = {
createPromise: <T>(
callback: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (error: Error) => void,
onCancel: (callback: () => void) => void,
) => void,
) => Promise<T>;
};
type CurlDownloadOptions = {
headers?: OutgoingHttpHeaders | null;
sha2?: string | null;
sha512?: string | null;
cancellationToken: CancellationTokenLike;
};
export type CurlHttpExecutor = {
request: (
options: RequestOptions,
cancellationToken?: CancellationTokenLike,
data?: Record<string, unknown> | null,
) => Promise<string | null>;
download: (url: URL, destination: string, options: CurlDownloadOptions) => Promise<string>;
downloadToBuffer: (url: URL, options: CurlDownloadOptions) => Promise<Buffer>;
};
function requestOptionsToUrl(options: RequestOptions): string {
const protocol = options.protocol ?? 'https:';
const hostname = options.hostname ?? options.host;
if (!hostname) throw new Error('Updater request is missing a hostname.');
const port = options.port ? `:${options.port}` : '';
const requestPath = options.path ?? '/';
return `${protocol}//${hostname}${port}${requestPath}`;
}
function addHeaderArgs(
args: string[],
headers: RequestOptions['headers'] | OutgoingHttpHeaders | null | undefined,
): void {
if (Array.isArray(headers)) {
for (let index = 0; index < headers.length; index += 2) {
const name = headers[index];
const value = headers[index + 1];
if (name !== undefined && value !== undefined) {
args.push('--header', `${name}: ${value}`);
}
}
return;
}
for (const [name, value] of Object.entries(headers ?? {})) {
if (value === undefined) continue;
const values = Array.isArray(value) ? value : [value];
for (const item of values) {
args.push('--header', `${name}: ${String(item)}`);
}
}
}
function buildBaseArgs(timeoutMs?: number): string[] {
const args = ['--fail', '--location', '--silent', '--show-error', '--connect-timeout', '30'];
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
args.push('--max-time', String(Math.max(1, Math.ceil(timeoutMs / 1000))));
}
return args;
}
function runCurl<T>(options: {
execFile: CurlExecFile;
curlPath: string;
args: readonly string[];
encoding: 'utf8' | 'buffer';
maxBuffer?: number;
timeout?: number;
cancellationToken?: CancellationTokenLike;
}): Promise<T> {
const run = (
resolve: (value: T) => void,
reject: (error: Error) => void,
onCancel: (callback: () => void) => void,
) => {
const child = options.execFile(
options.curlPath,
options.args,
{
encoding: options.encoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
},
(error, stdout, stderr) => {
if (error) {
const stderrMessage = Buffer.isBuffer(stderr) ? stderr.toString('utf8') : stderr;
const errno = (error as NodeJS.ErrnoException).code;
const safeFallback = errno ? `curl failed (${errno})` : 'curl failed';
reject(new Error(stderrMessage.trim() || safeFallback));
return;
}
resolve(stdout as T);
},
);
onCancel(() => {
child.kill('SIGTERM');
});
};
if (options.cancellationToken) {
return options.cancellationToken.createPromise<T>(run);
}
return new Promise<T>((resolve, reject) => run(resolve, reject, () => {}));
}
export function createCurlHttpExecutor(
options: {
execFile?: CurlExecFile;
curlPath?: string;
mkdir?: (targetPath: string) => Promise<unknown>;
readFile?: (targetPath: string) => Promise<Buffer>;
} = {},
): CurlHttpExecutor {
const execFile = options.execFile ?? (defaultExecFile as unknown as CurlExecFile);
const curlPath = options.curlPath ?? '/usr/bin/curl';
const mkdir =
options.mkdir ?? ((targetPath: string) => fs.promises.mkdir(targetPath, { recursive: true }));
const readFile = options.readFile ?? ((targetPath: string) => fs.promises.readFile(targetPath));
async function verifyDownloadedFile(destination: string, downloadOptions: CurlDownloadOptions) {
if (!downloadOptions.sha512 && !downloadOptions.sha2) return;
const data = await readFile(destination);
if (downloadOptions.sha512) {
const actual = createHash('sha512').update(data).digest('base64');
if (actual !== downloadOptions.sha512) {
throw new Error(`sha512 mismatch: expected ${downloadOptions.sha512}, got ${actual}`);
}
}
if (downloadOptions.sha2) {
const actual = createHash('sha256').update(data).digest('hex');
if (actual !== downloadOptions.sha2.toLowerCase()) {
throw new Error(`sha2 mismatch: expected ${downloadOptions.sha2}, got ${actual}`);
}
}
}
return {
async request(requestOptions, cancellationToken, data): Promise<string | null> {
const args = buildBaseArgs(requestOptions.timeout);
addHeaderArgs(args, requestOptions.headers);
if (requestOptions.method && requestOptions.method !== 'GET') {
args.push('--request', requestOptions.method);
}
if (data) {
args.push('--data-binary', JSON.stringify(data));
}
args.push(requestOptionsToUrl(requestOptions));
const result = await runCurl<string>({
execFile,
curlPath,
args,
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
timeout: requestOptions.timeout,
cancellationToken,
});
return result.length === 0 ? null : result;
},
async download(url, destination, downloadOptions): Promise<string> {
await mkdir(path.dirname(destination));
const args = buildBaseArgs();
addHeaderArgs(args, downloadOptions.headers);
args.push('--output', destination, url.href);
await runCurl<Buffer>({
execFile,
curlPath,
args,
encoding: 'buffer',
maxBuffer: 1024 * 1024,
cancellationToken: downloadOptions.cancellationToken,
});
await verifyDownloadedFile(destination, downloadOptions);
return destination;
},
async downloadToBuffer(url, downloadOptions): Promise<Buffer> {
const args = buildBaseArgs();
addHeaderArgs(args, downloadOptions.headers);
args.push(url.href);
return await runCurl<Buffer>({
execFile,
curlPath,
args,
encoding: 'buffer',
maxBuffer: 600 * 1024 * 1024,
cancellationToken: downloadOptions.cancellationToken,
});
},
};
}
@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElectronNetFetch } from './fetch-adapter';
import type { FetchResponseLike } from './release-assets';
test('createElectronNetFetch delegates updater requests to Electron net.fetch', async () => {
const calls: Array<{ url: string; init?: Record<string, unknown> }> = [];
const response: FetchResponseLike = {
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ ok: true }),
text: async () => 'ok',
arrayBuffer: async () => new ArrayBuffer(0),
};
const fetch = createElectronNetFetch({
fetch: async (url, init) => {
calls.push({ url, init });
return response;
},
});
const result = await fetch('https://api.github.com/repos/ksyasuda/SubMiner/releases', {
headers: { 'User-Agent': 'SubMiner updater' },
});
assert.equal(result, response);
assert.deepEqual(calls, [
{
url: 'https://api.github.com/repos/ksyasuda/SubMiner/releases',
init: { headers: { 'User-Agent': 'SubMiner updater' } },
},
]);
});
+9
View File
@@ -0,0 +1,9 @@
import type { FetchLike, FetchResponseLike } from './release-assets';
export interface ElectronNetFetchLike {
fetch: (url: string, init?: Record<string, unknown>) => Promise<FetchResponseLike>;
}
export function createElectronNetFetch(net: ElectronNetFetchLike): FetchLike {
return (url, init) => net.fetch(url, init);
}
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
test('macOS release metadata fetch is skipped only when native updater is unsupported', () => {
assert.equal(
shouldFetchReleaseMetadataForPlatform('darwin', {
available: false,
version: '0.14.0',
canUpdate: false,
}),
false,
);
assert.equal(
shouldFetchReleaseMetadataForPlatform('darwin', {
available: false,
version: '0.14.0',
}),
true,
);
assert.equal(
shouldFetchReleaseMetadataForPlatform('darwin', {
available: true,
version: '0.15.0',
canUpdate: true,
}),
true,
);
});
test('non-macOS release metadata fetch is not gated by native updater support', () => {
assert.equal(
shouldFetchReleaseMetadataForPlatform('linux', {
available: false,
version: '0.14.0',
canUpdate: false,
}),
true,
);
assert.equal(
shouldFetchReleaseMetadataForPlatform('win32', {
available: false,
version: '0.14.0',
canUpdate: false,
}),
true,
);
});
@@ -0,0 +1,15 @@
type AppUpdateMetadata = {
available: boolean;
version: string;
canUpdate?: boolean;
};
export function shouldFetchReleaseMetadataForPlatform(
platform: NodeJS.Platform,
appUpdate: AppUpdateMetadata,
): boolean {
if (platform !== 'darwin') {
return true;
}
return appUpdate.canUpdate !== false;
}
+66 -5
View File
@@ -151,11 +151,72 @@ test('manual update check does not prompt restart when only launcher updates', a
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'update-available');
assert.deepEqual(calls, [
'available-dialog:0.15.0',
'launcher:stable',
'manual-install:0.15.0',
]);
assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable', 'manual-install:0.15.0']);
});
test('manual update check can skip release metadata after unsupported app updater', async () => {
const { deps, calls } = createDeps({
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
shouldFetchReleaseMetadata: ({ appUpdate }) => appUpdate.canUpdate !== false,
fetchLatestStableRelease: async () => {
calls.push('fetch-release');
return {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [],
};
},
} as Partial<UpdateServiceDeps>);
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'up-to-date');
assert.deepEqual(calls, ['no-update:0.14.0']);
});
test('manual update check fetches release metadata after app metadata errors', async () => {
const { deps, calls } = createDeps({
checkAppUpdate: async () => {
throw new Error('latest-mac.yml missing');
},
shouldFetchReleaseMetadata: ({ appUpdate }) => appUpdate.canUpdate !== false,
fetchLatestStableRelease: async () => {
calls.push('fetch-release');
return {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [],
};
},
} as Partial<UpdateServiceDeps>);
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'update-available');
assert.deepEqual(calls, ['fetch-release', 'available-dialog:0.15.0']);
});
test('manual update check reports non-Error failures safely', async () => {
const { deps, calls } = createDeps({
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
showUpdateAvailableDialog: async (version) => {
calls.push(`available-dialog:${version}`);
return 'update';
},
downloadAppUpdate: async () => {
throw 'download rejected';
},
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.deepEqual(result, { status: 'failed', error: 'download rejected' });
assert.deepEqual(calls, ['available-dialog:0.15.0', 'failed:download rejected']);
});
test('automatic update check skips inside configured interval', async () => {
+30 -20
View File
@@ -30,15 +30,24 @@ export interface UpdateCheckResult {
error?: string;
}
type AppUpdateMetadata = {
available: boolean;
version: string;
canUpdate?: boolean;
};
export interface UpdateServiceDeps {
getConfig: () => Required<UpdatesConfig>;
getCurrentVersion: () => string;
now: () => number;
readState: () => Promise<UpdateState>;
writeState: (state: UpdateState) => Promise<void>;
checkAppUpdate: (
channel: UpdateChannel,
) => Promise<{ available: boolean; version: string; canUpdate?: boolean }>;
checkAppUpdate: (channel: UpdateChannel) => Promise<AppUpdateMetadata>;
shouldFetchReleaseMetadata?: (input: {
request: UpdateCheckRequest;
channel: UpdateChannel;
appUpdate: AppUpdateMetadata;
}) => boolean;
fetchLatestStableRelease: (channel: UpdateChannel) => Promise<GitHubRelease | null>;
updateLauncher: (
launcherPath?: string,
@@ -112,22 +121,23 @@ export function createUpdateService(deps: UpdateServiceDeps) {
}
try {
const [appUpdate, release] = await Promise.all([
deps.checkAppUpdate(channel).catch((error) => {
if (isAutomatic) {
deps.log(`App update metadata check failed: ${summarizeError(error)}`);
}
return {
available: false,
version: deps.getCurrentVersion(),
canUpdate: false,
};
}),
deps.fetchLatestStableRelease(channel).catch((error) => {
deps.log(`GitHub release update check failed: ${(error as Error).message}`);
return null;
}),
]);
const appUpdate: AppUpdateMetadata = await deps.checkAppUpdate(channel).catch((error) => {
if (isAutomatic) {
deps.log(`App update metadata check failed: ${summarizeError(error)}`);
}
return {
available: false,
version: deps.getCurrentVersion(),
};
});
const shouldFetchReleaseMetadata =
deps.shouldFetchReleaseMetadata?.({ request, channel, appUpdate }) ?? true;
const release = shouldFetchReleaseMetadata
? await deps.fetchLatestStableRelease(channel).catch((error) => {
deps.log(`GitHub release update check failed: ${summarizeError(error)}`);
return null;
})
: null;
const currentVersion = deps.getCurrentVersion();
const latest = getBestLatestVersion(currentVersion, appUpdate, release);
@@ -181,7 +191,7 @@ export function createUpdateService(deps: UpdateServiceDeps) {
}
return { status: 'updated', version: latest.version };
} catch (error) {
const message = (error as Error).message;
const message = summarizeError(error);
if (isAutomatic) {
deps.log(`Automatic update check failed: ${message}`);
} else {