mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
feat(macos): configuration window + curl-backed macOS updater (#71)
This commit is contained in:
@@ -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: () => {},
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
Reference in New Issue
Block a user