mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-15 20:12:59 -07:00
feat: add auto update support
This commit is contained in:
@@ -51,6 +51,24 @@ test('parseArgs ignores missing value after --log-level', () => {
|
||||
assert.equal(args.start, true);
|
||||
});
|
||||
|
||||
test('parseArgs captures update command and internal launcher paths', () => {
|
||||
const args = parseArgs([
|
||||
'--update',
|
||||
'--update-launcher-path',
|
||||
'/home/kyle/.local/bin/subminer',
|
||||
'--update-response-path',
|
||||
'/tmp/subminer-update-response.json',
|
||||
]);
|
||||
|
||||
assert.equal(args.update, true);
|
||||
assert.equal(args.updateLauncherPath, '/home/kyle/.local/bin/subminer');
|
||||
assert.equal(args.updateResponsePath, '/tmp/subminer-update-response.json');
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
assert.equal(commandNeedsOverlayRuntime(args), false);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(args), false);
|
||||
});
|
||||
|
||||
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
|
||||
const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']);
|
||||
assert.equal(args.launchMpv, true);
|
||||
@@ -182,6 +200,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(shouldStartApp(refreshKnownWords), true);
|
||||
assert.equal(isHeadlessInitialCommand(refreshKnownWords), true);
|
||||
|
||||
const update = parseArgs(['--update']);
|
||||
assert.equal(update.update, true);
|
||||
assert.equal(hasExplicitCommand(update), true);
|
||||
assert.equal(shouldStartApp(update), true);
|
||||
assert.equal(isHeadlessInitialCommand(update), true);
|
||||
|
||||
const settings = parseArgs(['--settings']);
|
||||
assert.equal(settings.settings, true);
|
||||
assert.equal(hasExplicitCommand(settings), true);
|
||||
|
||||
+26
-3
@@ -73,6 +73,9 @@ export interface CliArgs {
|
||||
texthooker: boolean;
|
||||
texthookerOpenBrowser: boolean;
|
||||
help: boolean;
|
||||
update?: boolean;
|
||||
updateLauncherPath?: string;
|
||||
updateResponsePath?: string;
|
||||
autoStartOverlay: boolean;
|
||||
generateConfig: boolean;
|
||||
configPath?: string;
|
||||
@@ -167,6 +170,9 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
texthooker: false,
|
||||
texthookerOpenBrowser: false,
|
||||
help: false,
|
||||
update: false,
|
||||
updateLauncherPath: undefined,
|
||||
updateResponsePath: undefined,
|
||||
autoStartOverlay: false,
|
||||
generateConfig: false,
|
||||
backupOverwrite: false,
|
||||
@@ -330,7 +336,20 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
|
||||
else if (arg === '--texthooker') args.texthooker = true;
|
||||
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
|
||||
else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
|
||||
else if (arg === '--update') args.update = true;
|
||||
else if (arg.startsWith('--update-launcher-path=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value) args.updateLauncherPath = value;
|
||||
} else if (arg === '--update-launcher-path') {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.updateLauncherPath = value;
|
||||
} else if (arg.startsWith('--update-response-path=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value) args.updateResponsePath = value;
|
||||
} else if (arg === '--update-response-path') {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.updateResponsePath = value;
|
||||
} else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
|
||||
else if (arg === '--generate-config') args.generateConfig = true;
|
||||
else if (arg === '--backup-overwrite') args.backupOverwrite = true;
|
||||
else if (arg === '--help') args.help = true;
|
||||
@@ -517,13 +536,14 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth ||
|
||||
args.texthooker ||
|
||||
args.update ||
|
||||
args.generateConfig ||
|
||||
args.help
|
||||
);
|
||||
}
|
||||
|
||||
export function isHeadlessInitialCommand(args: CliArgs): boolean {
|
||||
return args.refreshKnownWords;
|
||||
return args.refreshKnownWords || args.update === true;
|
||||
}
|
||||
|
||||
export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
@@ -587,6 +607,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.jellyfinPlay &&
|
||||
!args.jellyfinRemoteAnnounce &&
|
||||
!args.jellyfinPreviewAuth &&
|
||||
!args.update &&
|
||||
!args.help &&
|
||||
!args.autoStartOverlay &&
|
||||
!args.generateConfig
|
||||
@@ -638,7 +659,8 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.stats ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinPlay ||
|
||||
args.texthooker
|
||||
args.texthooker ||
|
||||
args.update
|
||||
) {
|
||||
if (args.launchMpv) {
|
||||
return false;
|
||||
@@ -708,6 +730,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.jellyfinRemoteAnnounce &&
|
||||
!args.jellyfinPreviewAuth &&
|
||||
!args.texthooker &&
|
||||
!args.update &&
|
||||
!args.help &&
|
||||
!args.autoStartOverlay &&
|
||||
!args.generateConfig &&
|
||||
|
||||
@@ -17,6 +17,7 @@ ${B}Session${R}
|
||||
--stats Open the stats dashboard in your browser
|
||||
--texthooker Start texthooker server only ${D}(no overlay)${R}
|
||||
--open-browser Open texthooker in your default browser
|
||||
--update Check for updates
|
||||
|
||||
${B}Overlay${R}
|
||||
--toggle-visible-overlay Toggle subtitle overlay
|
||||
|
||||
@@ -109,6 +109,58 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.immersionTracking.lifetimeSummaries?.anime, true);
|
||||
assert.equal(config.immersionTracking.lifetimeSummaries?.media, true);
|
||||
assert.equal(config.stats.autoOpenBrowser, false);
|
||||
assert.equal(config.updates.enabled, true);
|
||||
assert.equal(config.updates.checkIntervalHours, 24);
|
||||
assert.equal(config.updates.notificationType, 'system');
|
||||
assert.equal(config.updates.channel, 'stable');
|
||||
});
|
||||
|
||||
test('parses updates config and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"updates": {
|
||||
"enabled": false,
|
||||
"checkIntervalHours": 6,
|
||||
"notificationType": "both",
|
||||
"channel": "prerelease"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().updates.enabled, false);
|
||||
assert.equal(validService.getConfig().updates.checkIntervalHours, 6);
|
||||
assert.equal(validService.getConfig().updates.notificationType, 'both');
|
||||
assert.equal(validService.getConfig().updates.channel, 'prerelease');
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"updates": {
|
||||
"enabled": "yes",
|
||||
"checkIntervalHours": 0,
|
||||
"notificationType": "toast",
|
||||
"channel": "nightly"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
const config = invalidService.getConfig();
|
||||
const warnings = invalidService.getWarnings();
|
||||
assert.equal(config.updates.enabled, DEFAULT_CONFIG.updates.enabled);
|
||||
assert.equal(config.updates.checkIntervalHours, DEFAULT_CONFIG.updates.checkIntervalHours);
|
||||
assert.equal(config.updates.notificationType, DEFAULT_CONFIG.updates.notificationType);
|
||||
assert.equal(config.updates.channel, DEFAULT_CONFIG.updates.channel);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'updates.enabled'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'updates.checkIntervalHours'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'updates.notificationType'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'updates.channel'));
|
||||
});
|
||||
|
||||
test('throws actionable startup parse error for malformed config at construction time', () => {
|
||||
@@ -2124,6 +2176,7 @@ test('template generator includes known keys', () => {
|
||||
assert.match(output, /"websocket":/);
|
||||
assert.match(output, /"discordPresence":/);
|
||||
assert.match(output, /"startupWarmups":/);
|
||||
assert.match(output, /"updates":/);
|
||||
assert.match(output, /"youtube":/);
|
||||
assert.doesNotMatch(output, /"youtubeSubgen":/);
|
||||
assert.match(output, /"characterDictionary":\s*\{/);
|
||||
@@ -2210,6 +2263,14 @@ test('template generator includes known keys', () => {
|
||||
output,
|
||||
/"autoOpenBrowser": false,? \/\/ Automatically open the stats dashboard in a browser when the server starts\. Values: true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"notificationType": "system",? \/\/ How SubMiner announces available updates\. Values: system \| osd \| both \| none/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"channel": "stable",? \/\/ Release channel used for update checks\. Values: stable \| prerelease/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./,
|
||||
|
||||
@@ -33,6 +33,7 @@ const {
|
||||
youtube,
|
||||
subsync,
|
||||
startupWarmups,
|
||||
updates,
|
||||
auto_start_overlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||
@@ -55,6 +56,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
youtube,
|
||||
subsync,
|
||||
startupWarmups,
|
||||
updates,
|
||||
subtitleStyle,
|
||||
subtitleSidebar,
|
||||
auto_start_overlay,
|
||||
|
||||
@@ -14,6 +14,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'youtube'
|
||||
| 'subsync'
|
||||
| 'startupWarmups'
|
||||
| 'updates'
|
||||
| 'auto_start_overlay'
|
||||
> = {
|
||||
subtitlePosition: { yPercent: 10 },
|
||||
@@ -116,5 +117,11 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
subtitleDictionaries: true,
|
||||
jellyfinRemoteSession: true,
|
||||
},
|
||||
updates: {
|
||||
enabled: true,
|
||||
checkIntervalHours: 24,
|
||||
notificationType: 'system',
|
||||
channel: 'stable',
|
||||
},
|
||||
auto_start_overlay: false,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
'controller.enabled',
|
||||
'controller.scrollPixelsPerSecond',
|
||||
'startupWarmups.lowPowerMode',
|
||||
'updates.channel',
|
||||
'youtube.primarySubLanguages',
|
||||
'subtitleStyle.enableJlpt',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
|
||||
@@ -383,6 +383,32 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.startupWarmups.jellyfinRemoteSession,
|
||||
description: 'Warm up Jellyfin remote session at startup.',
|
||||
},
|
||||
{
|
||||
path: 'updates.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.updates.enabled,
|
||||
description: 'Run automatic update checks in the background.',
|
||||
},
|
||||
{
|
||||
path: 'updates.checkIntervalHours',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.updates.checkIntervalHours,
|
||||
description: 'Minimum hours between automatic update checks.',
|
||||
},
|
||||
{
|
||||
path: 'updates.notificationType',
|
||||
kind: 'enum',
|
||||
enumValues: ['system', 'osd', 'both', 'none'],
|
||||
defaultValue: defaultConfig.updates.notificationType,
|
||||
description: 'How SubMiner announces available updates.',
|
||||
},
|
||||
{
|
||||
path: 'updates.channel',
|
||||
kind: 'enum',
|
||||
enumValues: ['stable', 'prerelease'],
|
||||
defaultValue: defaultConfig.updates.channel,
|
||||
description: 'Release channel used for update checks.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.multiCopyTimeoutMs',
|
||||
kind: 'number',
|
||||
|
||||
@@ -53,6 +53,14 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
],
|
||||
key: 'startupWarmups',
|
||||
},
|
||||
{
|
||||
title: 'Updates',
|
||||
description: [
|
||||
'Automatic update check behavior.',
|
||||
'Manual checks from the tray or launcher are always allowed.',
|
||||
],
|
||||
key: 'updates',
|
||||
},
|
||||
{
|
||||
title: 'Keyboard Shortcuts',
|
||||
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
|
||||
|
||||
@@ -478,6 +478,62 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.updates)) {
|
||||
const enabled = asBoolean(src.updates.enabled);
|
||||
if (enabled !== undefined) {
|
||||
resolved.updates.enabled = enabled;
|
||||
} else if (src.updates.enabled !== undefined) {
|
||||
warn('updates.enabled', src.updates.enabled, resolved.updates.enabled, 'Expected boolean.');
|
||||
}
|
||||
|
||||
const checkIntervalHours = asNumber(src.updates.checkIntervalHours);
|
||||
if (
|
||||
checkIntervalHours !== undefined &&
|
||||
Number.isFinite(checkIntervalHours) &&
|
||||
checkIntervalHours > 0
|
||||
) {
|
||||
resolved.updates.checkIntervalHours = checkIntervalHours;
|
||||
} else if (src.updates.checkIntervalHours !== undefined) {
|
||||
warn(
|
||||
'updates.checkIntervalHours',
|
||||
src.updates.checkIntervalHours,
|
||||
resolved.updates.checkIntervalHours,
|
||||
'Expected positive number.',
|
||||
);
|
||||
}
|
||||
|
||||
const notificationType = asString(src.updates.notificationType);
|
||||
if (
|
||||
notificationType === 'system' ||
|
||||
notificationType === 'osd' ||
|
||||
notificationType === 'both' ||
|
||||
notificationType === 'none'
|
||||
) {
|
||||
resolved.updates.notificationType = notificationType;
|
||||
} else if (src.updates.notificationType !== undefined) {
|
||||
warn(
|
||||
'updates.notificationType',
|
||||
src.updates.notificationType,
|
||||
resolved.updates.notificationType,
|
||||
'Expected system, osd, both, or none.',
|
||||
);
|
||||
}
|
||||
|
||||
const channel = asString(src.updates.channel);
|
||||
if (channel === 'stable' || channel === 'prerelease') {
|
||||
resolved.updates.channel = channel;
|
||||
} else if (src.updates.channel !== undefined) {
|
||||
warn(
|
||||
'updates.channel',
|
||||
src.updates.channel,
|
||||
resolved.updates.channel,
|
||||
'Expected stable or prerelease.',
|
||||
);
|
||||
}
|
||||
} else if (src.updates !== undefined) {
|
||||
warn('updates', src.updates, resolved.updates, 'Expected object.');
|
||||
}
|
||||
|
||||
if (isObject(src.shortcuts)) {
|
||||
const shortcutKeys = [
|
||||
'toggleVisibleOverlayGlobal',
|
||||
|
||||
@@ -70,6 +70,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
texthooker: false,
|
||||
texthookerOpenBrowser: false,
|
||||
help: false,
|
||||
update: false,
|
||||
updateLauncherPath: undefined,
|
||||
autoStartOverlay: false,
|
||||
generateConfig: false,
|
||||
backupOverwrite: false,
|
||||
@@ -231,6 +233,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
runYoutubePlaybackFlow: async (request) => {
|
||||
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`);
|
||||
},
|
||||
runUpdateCommand: async (args) => {
|
||||
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
|
||||
},
|
||||
printHelp: () => {
|
||||
calls.push('printHelp');
|
||||
},
|
||||
@@ -363,6 +368,34 @@ test('handleCliCommand opens first-run setup window for --setup', () => {
|
||||
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
|
||||
});
|
||||
|
||||
test('handleCliCommand runs update command without overlay startup', async () => {
|
||||
const { deps, calls } = createDeps();
|
||||
|
||||
handleCliCommand(
|
||||
makeArgs({ update: true, updateLauncherPath: '/home/kyle/.local/bin/subminer' }),
|
||||
'initial',
|
||||
deps,
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(calls, ['runUpdateCommand:/home/kyle/.local/bin/subminer']);
|
||||
});
|
||||
|
||||
test('handleCliCommand stops app after headless initial update completes', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
hasMainWindow: () => false,
|
||||
});
|
||||
|
||||
handleCliCommand(
|
||||
makeArgs({ update: true, updateLauncherPath: '/home/kyle/.local/bin/subminer' }),
|
||||
'initial',
|
||||
deps,
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(calls, ['runUpdateCommand:/home/kyle/.local/bin/subminer', 'stopApp']);
|
||||
});
|
||||
|
||||
test('handleCliCommand dispatches stats command without overlay startup', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
runStatsCommand: async () => {
|
||||
|
||||
@@ -95,6 +95,7 @@ export interface CliCommandServiceDeps {
|
||||
}) => Promise<CharacterDictionarySelectionResult>;
|
||||
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
url: string;
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
@@ -174,6 +175,7 @@ interface AnilistCliRuntime {
|
||||
interface AppCliRuntime {
|
||||
stop: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
||||
}
|
||||
|
||||
@@ -277,6 +279,7 @@ export function createCliCommandDepsRuntime(
|
||||
setCharacterDictionarySelection: options.dictionary.setSelection,
|
||||
runStatsCommand: options.jellyfin.runStatsCommand,
|
||||
runJellyfinCommand: options.jellyfin.runCommand,
|
||||
runUpdateCommand: options.app.runUpdateCommand,
|
||||
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
|
||||
printHelp: options.ui.printHelp,
|
||||
hasMainWindow: options.app.hasMainWindow,
|
||||
@@ -416,6 +419,19 @@ export function handleCliCommand(
|
||||
deps.stopApp();
|
||||
}
|
||||
});
|
||||
} else if (args.update) {
|
||||
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
|
||||
deps
|
||||
.runUpdateCommand(args, source)
|
||||
.catch((err) => {
|
||||
deps.error('runUpdateCommand failed:', err);
|
||||
deps.showMpvOsd(`Update failed: ${(err as Error).message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
if (shouldStopAfterRun) {
|
||||
deps.stopApp();
|
||||
}
|
||||
});
|
||||
} else if (args.toggleSecondarySub) {
|
||||
deps.cycleSecondarySubMode();
|
||||
} else if (args.triggerFieldGrouping) {
|
||||
|
||||
@@ -27,7 +27,7 @@ export {
|
||||
isAutoUpdateEnabledRuntime,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig,
|
||||
} from './startup';
|
||||
export { openYomitanSettingsWindow } from './yomitan-settings';
|
||||
export { destroyYomitanSettingsWindow, openYomitanSettingsWindow } from './yomitan-settings';
|
||||
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
|
||||
export {
|
||||
addYomitanNoteViaSearch,
|
||||
|
||||
@@ -223,3 +223,23 @@ test('runStartupBootstrapRuntime enables quiet background mode by default', () =
|
||||
assert.equal(result.backgroundMode, true);
|
||||
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
|
||||
});
|
||||
|
||||
test('runStartupBootstrapRuntime enables quiet update mode by default', () => {
|
||||
const calls: string[] = [];
|
||||
const args = makeArgs({ update: true });
|
||||
|
||||
const result = runStartupBootstrapRuntime({
|
||||
argv: ['node', 'main.ts', '--update'],
|
||||
parseArgs: () => args,
|
||||
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
|
||||
forceX11Backend: () => calls.push('forceX11'),
|
||||
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
|
||||
getDefaultSocketPath: () => '/tmp/default.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
runGenerateConfigFlow: () => false,
|
||||
startAppLifecycle: () => calls.push('startLifecycle'),
|
||||
});
|
||||
|
||||
assert.equal(result.backgroundMode, false);
|
||||
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ export function runStartupBootstrapRuntime(
|
||||
|
||||
if (initialArgs.logLevel) {
|
||||
deps.setLogLevel(initialArgs.logLevel, 'cli');
|
||||
} else if (initialArgs.background) {
|
||||
} else if (initialArgs.background || initialArgs.update) {
|
||||
deps.setLogLevel('warn', 'cli');
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ const YOMITAN_SYNC_SCRIPT_PATHS = [
|
||||
path.join('js', 'display', 'display-audio.js'),
|
||||
];
|
||||
|
||||
type ExtensionCopyResult = {
|
||||
targetDir: string;
|
||||
copied: boolean;
|
||||
};
|
||||
|
||||
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
|
||||
|
||||
function readManifestVersion(manifestPath: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { version?: unknown };
|
||||
@@ -18,6 +25,15 @@ function readManifestVersion(manifestPath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hashDirectoryContents(dirPath: string): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||
@@ -53,6 +69,42 @@ export function hashDirectoryContents(dirPath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export async function hashDirectoryContentsAsync(dirPath: string): Promise<string | null> {
|
||||
try {
|
||||
const dirStat = await fs.promises.stat(dirPath);
|
||||
if (!dirStat.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hash = createHash('sha256');
|
||||
const queue = [''];
|
||||
while (queue.length > 0) {
|
||||
const relativeDir = queue.shift()!;
|
||||
const absoluteDir = path.join(dirPath, relativeDir);
|
||||
const entries = await fs.promises.readdir(absoluteDir, { withFileTypes: true });
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
const normalizedRelativePath = relativePath.split(path.sep).join('/');
|
||||
hash.update(normalizedRelativePath);
|
||||
if (entry.isDirectory()) {
|
||||
queue.push(relativePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
hash.update(await fs.promises.readFile(path.join(dirPath, relativePath)));
|
||||
}
|
||||
}
|
||||
|
||||
return hash.digest('hex');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function areFilesEqual(sourcePath: string, targetPath: string): boolean {
|
||||
if (!fs.existsSync(sourcePath) || !fs.existsSync(targetPath)) return false;
|
||||
try {
|
||||
@@ -93,10 +145,7 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
|
||||
export function ensureExtensionCopy(
|
||||
sourceDir: string,
|
||||
userDataPath: string,
|
||||
): {
|
||||
targetDir: string;
|
||||
copied: boolean;
|
||||
} {
|
||||
): ExtensionCopyResult {
|
||||
if (process.platform === 'win32') {
|
||||
return { targetDir: sourceDir, copied: false };
|
||||
}
|
||||
@@ -117,3 +166,53 @@ export function ensureExtensionCopy(
|
||||
|
||||
return { targetDir, copied: shouldCopy };
|
||||
}
|
||||
|
||||
export async function ensureExtensionCopyAsync(
|
||||
sourceDir: string,
|
||||
userDataPath: string,
|
||||
): Promise<ExtensionCopyResult> {
|
||||
if (process.platform === 'win32') {
|
||||
return { targetDir: sourceDir, copied: false };
|
||||
}
|
||||
|
||||
const extensionsRoot = path.join(userDataPath, 'extensions');
|
||||
const targetDir = path.join(extensionsRoot, 'yomitan');
|
||||
const inFlightKey = path.resolve(targetDir);
|
||||
const inFlight = asyncExtensionCopyInFlight.get(inFlightKey);
|
||||
if (inFlight) {
|
||||
return await inFlight;
|
||||
}
|
||||
|
||||
const copyPromise = ensureExtensionCopyAsyncInternal(sourceDir, extensionsRoot, targetDir);
|
||||
asyncExtensionCopyInFlight.set(inFlightKey, copyPromise);
|
||||
try {
|
||||
return await copyPromise;
|
||||
} finally {
|
||||
if (asyncExtensionCopyInFlight.get(inFlightKey) === copyPromise) {
|
||||
asyncExtensionCopyInFlight.delete(inFlightKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureExtensionCopyAsyncInternal(
|
||||
sourceDir: string,
|
||||
extensionsRoot: string,
|
||||
targetDir: string,
|
||||
): Promise<ExtensionCopyResult> {
|
||||
let shouldCopy = !(await pathExists(targetDir));
|
||||
if (!shouldCopy) {
|
||||
const [sourceHash, targetHash] = await Promise.all([
|
||||
hashDirectoryContentsAsync(sourceDir),
|
||||
hashDirectoryContentsAsync(targetDir),
|
||||
]);
|
||||
shouldCopy = sourceHash !== targetHash;
|
||||
}
|
||||
|
||||
if (shouldCopy) {
|
||||
await fs.promises.mkdir(extensionsRoot, { recursive: true });
|
||||
await fs.promises.rm(targetDir, { recursive: true, force: true });
|
||||
await fs.promises.cp(sourceDir, targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
return { targetDir, copied: shouldCopy };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ensureExtensionCopy, shouldCopyYomitanExtension } from './yomitan-extension-copy';
|
||||
import {
|
||||
ensureExtensionCopy,
|
||||
ensureExtensionCopyAsync,
|
||||
shouldCopyYomitanExtension,
|
||||
} from './yomitan-extension-copy';
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
@@ -82,3 +86,115 @@ test('ensureExtensionCopy refreshes copied extension when display files change',
|
||||
'new display code',
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureExtensionCopyAsync refreshes copied extension without completing synchronously', async () => {
|
||||
const sourceRoot = makeTempDir('subminer-yomitan-src-');
|
||||
const userDataRoot = makeTempDir('subminer-yomitan-user-');
|
||||
|
||||
const sourceDir = path.join(sourceRoot, 'yomitan');
|
||||
const targetDir = path.join(userDataRoot, 'extensions', 'yomitan');
|
||||
|
||||
fs.mkdirSync(path.join(sourceDir, 'js', 'display'), { recursive: true });
|
||||
fs.mkdirSync(path.join(targetDir, 'js', 'display'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||
fs.writeFileSync(
|
||||
path.join(sourceDir, 'js', 'display', 'structured-content-generator.js'),
|
||||
'new display code',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
|
||||
'old display code',
|
||||
);
|
||||
|
||||
let completed = false;
|
||||
const resultPromise = ensureExtensionCopyAsync(sourceDir, userDataRoot).then((result) => {
|
||||
completed = true;
|
||||
return result;
|
||||
});
|
||||
|
||||
assert.equal(completed, false);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
assert.equal(result.targetDir, targetDir);
|
||||
assert.equal(result.copied, true);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
|
||||
'utf8',
|
||||
),
|
||||
'new display code',
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureExtensionCopyAsync shares an in-flight refresh for the same copied extension', async () => {
|
||||
const sourceRoot = makeTempDir('subminer-yomitan-src-');
|
||||
const userDataRoot = makeTempDir('subminer-yomitan-user-');
|
||||
|
||||
const sourceDir = path.join(sourceRoot, 'yomitan');
|
||||
const targetDir = path.join(userDataRoot, 'extensions', 'yomitan');
|
||||
|
||||
fs.mkdirSync(path.join(sourceDir, 'js', 'pages', 'settings'), { recursive: true });
|
||||
fs.mkdirSync(path.join(targetDir, 'js', 'pages', 'settings'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
|
||||
fs.writeFileSync(
|
||||
path.join(sourceDir, 'js', 'pages', 'settings', 'settings-main.js'),
|
||||
'new settings code',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'),
|
||||
'old settings code',
|
||||
);
|
||||
|
||||
const originalCp = fs.promises.cp;
|
||||
let cpCalls = 0;
|
||||
let firstCopyStarted = false;
|
||||
let releaseFirstCopy: () => void = () => {};
|
||||
const firstCopyStartedPromise = new Promise<void>((resolve) => {
|
||||
const fsPromises = fs.promises as typeof fs.promises & {
|
||||
cp: typeof fs.promises.cp;
|
||||
};
|
||||
fsPromises.cp = async (...args: Parameters<typeof fs.promises.cp>) => {
|
||||
cpCalls++;
|
||||
if (!firstCopyStarted) {
|
||||
firstCopyStarted = true;
|
||||
resolve();
|
||||
await new Promise<void>((release) => {
|
||||
releaseFirstCopy = release;
|
||||
});
|
||||
}
|
||||
return await originalCp(...args);
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const first = ensureExtensionCopyAsync(sourceDir, userDataRoot);
|
||||
await firstCopyStartedPromise;
|
||||
const second = ensureExtensionCopyAsync(sourceDir, userDataRoot);
|
||||
|
||||
releaseFirstCopy();
|
||||
const results = await Promise.all([first, second]);
|
||||
|
||||
assert.equal(cpCalls, 1);
|
||||
assert.equal(results[0].targetDir, targetDir);
|
||||
assert.equal(results[1].targetDir, targetDir);
|
||||
assert.equal(results[0].copied, true);
|
||||
assert.equal(results[1].copied, true);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'),
|
||||
'utf8',
|
||||
),
|
||||
'new settings code',
|
||||
);
|
||||
} finally {
|
||||
const fsPromises = fs.promises as typeof fs.promises & {
|
||||
cp: typeof fs.promises.cp;
|
||||
};
|
||||
fsPromises.cp = originalCp;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '../../logger';
|
||||
import { ensureExtensionCopy } from './yomitan-extension-copy';
|
||||
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
|
||||
import {
|
||||
getYomitanExtensionSearchPaths,
|
||||
resolveExternalYomitanExtensionPath,
|
||||
@@ -79,7 +79,7 @@ export async function loadYomitanExtension(
|
||||
return null;
|
||||
}
|
||||
|
||||
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
|
||||
const extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
|
||||
if (extensionCopy.copied) {
|
||||
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildYomitanSettingsUrl,
|
||||
configureYomitanSettingsWindowChrome,
|
||||
destroyYomitanSettingsWindow,
|
||||
showYomitanSettingsWindow,
|
||||
} from './yomitan-settings';
|
||||
|
||||
function assertGuardedBySubminerSettingsSafe(source: string, call: string): void {
|
||||
const callIndex = source.indexOf(call);
|
||||
assert.notEqual(callIndex, -1, `missing call: ${call}`);
|
||||
|
||||
const beforeCall = source.slice(0, callIndex);
|
||||
const guardIndex = beforeCall.lastIndexOf('if (!subminerSettingsSafe) {');
|
||||
const blockCloseIndex = beforeCall.lastIndexOf('\n }');
|
||||
assert.ok(
|
||||
guardIndex > blockCloseIndex,
|
||||
`${call} must be inside its own !subminerSettingsSafe startup guard`,
|
||||
);
|
||||
}
|
||||
|
||||
test('yomitan settings window removes default app menu quit action', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
configureYomitanSettingsWindowChrome({
|
||||
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
|
||||
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
|
||||
} as never);
|
||||
|
||||
assert.deepEqual(calls, ['auto-hide:true', 'menu:null']);
|
||||
});
|
||||
|
||||
test('yomitan settings URL disables the embedded popup preview', () => {
|
||||
assert.equal(
|
||||
buildYomitanSettingsUrl('abc123'),
|
||||
'chrome-extension://abc123/settings.html?popup-preview=false&subminer-settings-safe=true',
|
||||
);
|
||||
});
|
||||
|
||||
test('vendored Yomitan settings safe mode skips heavy startup controllers', () => {
|
||||
const source = readFileSync(
|
||||
'vendor/subminer-yomitan/ext/js/pages/settings/settings-main.js',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.match(source, /subminer-settings-safe/);
|
||||
assertGuardedBySubminerSettingsSafe(source, 'popupPreviewController.prepare()');
|
||||
assertGuardedBySubminerSettingsSafe(source, 'persistentStorageController.prepare()');
|
||||
assertGuardedBySubminerSettingsSafe(source, 'storageController.prepare()');
|
||||
assertGuardedBySubminerSettingsSafe(source, 'dictionaryController.prepare()');
|
||||
assertGuardedBySubminerSettingsSafe(source, 'ankiController.prepare()');
|
||||
assert.match(source, /if \(!subminerSettingsSafe\)[\s\S]*new AnkiDeckGeneratorController/);
|
||||
assert.match(source, /if \(!subminerSettingsSafe\)[\s\S]*new SecondarySearchDictionaryController/);
|
||||
assert.match(source, /if \(!subminerSettingsSafe\)[\s\S]*new SortFrequencyDictionaryController/);
|
||||
});
|
||||
|
||||
test('vendored Yomitan settings caches dictionary metadata requests', () => {
|
||||
const source = readFileSync(
|
||||
'vendor/subminer-yomitan/ext/js/pages/settings/settings-controller.js',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.match(source, /_dictionaryInfoPromise/);
|
||||
assert.match(source, /_dictionaryInfoCache/);
|
||||
assert.match(source, /databaseUpdated/);
|
||||
assert.match(
|
||||
source,
|
||||
/this\._dictionaryInfoPromise = this\._application\.api\.getDictionaryInfo\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('vendored Yomitan Anki settings reuses SettingsController dictionary metadata cache', () => {
|
||||
const source = readFileSync(
|
||||
'vendor/subminer-yomitan/ext/js/pages/settings/anki-controller.js',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.match(source, /this\._settingsController\.getDictionaryInfo\(\)/);
|
||||
assert.doesNotMatch(source, /this\._application\.api\.getDictionaryInfo\(\)/);
|
||||
});
|
||||
|
||||
test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existing window', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
showYomitanSettingsWindow({
|
||||
isDestroyed: () => false,
|
||||
isMinimized: () => true,
|
||||
restore: () => calls.push('restore'),
|
||||
getSize: () => [1200, 800],
|
||||
setSize: (width: number, height: number) => calls.push(`set-size:${width}x${height}`),
|
||||
webContents: {
|
||||
invalidate: () => calls.push('invalidate'),
|
||||
},
|
||||
show: () => calls.push('show'),
|
||||
focus: () => calls.push('focus'),
|
||||
} as never);
|
||||
|
||||
assert.deepEqual(calls, ['restore', 'set-size:1200x800', 'invalidate', 'show', 'focus']);
|
||||
});
|
||||
|
||||
test('destroyYomitanSettingsWindow destroys a live settings window before app quit', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const destroyed = destroyYomitanSettingsWindow({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy'),
|
||||
} as never);
|
||||
|
||||
assert.equal(destroyed, true);
|
||||
assert.deepEqual(calls, ['destroy']);
|
||||
});
|
||||
|
||||
test('destroyYomitanSettingsWindow skips missing or already destroyed settings windows', () => {
|
||||
assert.equal(destroyYomitanSettingsWindow(null), false);
|
||||
assert.equal(
|
||||
destroyYomitanSettingsWindow({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => {
|
||||
throw new Error('should not destroy twice');
|
||||
},
|
||||
} as never),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -13,6 +13,39 @@ export interface OpenYomitanSettingsWindowOptions {
|
||||
onWindowClosed?: () => void;
|
||||
}
|
||||
|
||||
export function configureYomitanSettingsWindowChrome(
|
||||
settingsWindow: Pick<BrowserWindow, 'setAutoHideMenuBar' | 'setMenu'>,
|
||||
): void {
|
||||
settingsWindow.setAutoHideMenuBar(true);
|
||||
settingsWindow.setMenu(null);
|
||||
}
|
||||
|
||||
export function buildYomitanSettingsUrl(extensionId: string): string {
|
||||
return `chrome-extension://${extensionId}/settings.html?popup-preview=false&subminer-settings-safe=true`;
|
||||
}
|
||||
|
||||
export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
|
||||
if (settingsWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
if (settingsWindow.isMinimized()) {
|
||||
settingsWindow.restore();
|
||||
}
|
||||
const [width = 0, height = 0] = settingsWindow.getSize();
|
||||
settingsWindow.setSize(width, height);
|
||||
settingsWindow.webContents.invalidate();
|
||||
settingsWindow.show();
|
||||
settingsWindow.focus();
|
||||
}
|
||||
|
||||
export function destroyYomitanSettingsWindow(settingsWindow: BrowserWindow | null): boolean {
|
||||
if (!settingsWindow || settingsWindow.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
settingsWindow.destroy();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOptions): void {
|
||||
logger.info('openYomitanSettings called');
|
||||
|
||||
@@ -24,8 +57,8 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
|
||||
const existingWindow = options.getExistingWindow();
|
||||
if (existingWindow && !existingWindow.isDestroyed()) {
|
||||
logger.info('Settings window already exists, focusing');
|
||||
existingWindow.focus();
|
||||
logger.info('Settings window already exists, showing and focusing');
|
||||
showYomitanSettingsWindow(existingWindow);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,15 +68,17 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
session: options.yomitanSession ?? session.defaultSession,
|
||||
},
|
||||
});
|
||||
configureYomitanSettingsWindowChrome(settingsWindow);
|
||||
options.setWindow(settingsWindow);
|
||||
|
||||
const settingsUrl = `chrome-extension://${options.yomitanExt.id}/settings.html`;
|
||||
const settingsUrl = buildYomitanSettingsUrl(options.yomitanExt.id);
|
||||
logger.info('Loading settings URL:', settingsUrl);
|
||||
|
||||
let loadAttempts = 0;
|
||||
@@ -76,12 +111,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!settingsWindow.isDestroyed()) {
|
||||
const [width = 0, height = 0] = settingsWindow.getSize();
|
||||
settingsWindow.setSize(width, height);
|
||||
settingsWindow.webContents.invalidate();
|
||||
settingsWindow.show();
|
||||
}
|
||||
showYomitanSettingsWindow(settingsWindow);
|
||||
}, 500);
|
||||
|
||||
settingsWindow.on('closed', () => {
|
||||
|
||||
+198
-2
@@ -124,6 +124,7 @@ import type {
|
||||
SecondarySubMode,
|
||||
SubtitleData,
|
||||
SubtitlePosition,
|
||||
UpdateChannel,
|
||||
WindowGeometry,
|
||||
} from './types';
|
||||
import { AnkiIntegration } from './anki-integration';
|
||||
@@ -313,6 +314,7 @@ import {
|
||||
createTokenizerDepsRuntime,
|
||||
cycleSecondarySubMode as cycleSecondarySubModeCore,
|
||||
deleteYomitanDictionaryByTitle,
|
||||
destroyYomitanSettingsWindow,
|
||||
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
|
||||
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
|
||||
getYomitanDictionaryInfo,
|
||||
@@ -393,6 +395,11 @@ import {
|
||||
detectWindowsMpvShortcuts,
|
||||
resolveWindowsMpvShortcutPaths,
|
||||
} from './main/runtime/windows-mpv-shortcuts';
|
||||
import {
|
||||
detectCommandLineLauncher,
|
||||
installBun as installCommandLineBun,
|
||||
installLauncher as installCommandLineLauncher,
|
||||
} from './main/runtime/command-line-launcher';
|
||||
import {
|
||||
createWindowsMpvLaunchDeps,
|
||||
getConfiguredWindowsMpvPathStatus,
|
||||
@@ -405,7 +412,10 @@ import {
|
||||
toggleJellyfinDiscoveryFromTray as toggleJellyfinDiscoveryFromTrayRuntime,
|
||||
} from './main/runtime/jellyfin-tray-discovery';
|
||||
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
|
||||
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
|
||||
import {
|
||||
shouldEnsureTrayOnStartupForInitialArgs,
|
||||
shouldQuitOnWindowAllClosedForTrayState,
|
||||
} from './main/runtime/startup-tray-policy';
|
||||
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
||||
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
|
||||
import {
|
||||
@@ -498,6 +508,31 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||
import { createElectronAppUpdater } from './main/runtime/update/app-updater';
|
||||
import {
|
||||
fetchLatestStableRelease,
|
||||
fetchReleaseAssetBuffer,
|
||||
fetchReleaseAssetText,
|
||||
findReleaseAsset,
|
||||
parseSha256Sums,
|
||||
} from './main/runtime/update/release-assets';
|
||||
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
|
||||
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
|
||||
import {
|
||||
showNoUpdateDialog,
|
||||
showRestartDialog,
|
||||
showUpdateAvailableDialog,
|
||||
showUpdateFailedDialog,
|
||||
} from './main/runtime/update/update-dialogs';
|
||||
import {
|
||||
runUpdateCliCommand,
|
||||
writeUpdateCliCommandResponse,
|
||||
} from './main/runtime/update/update-cli-command';
|
||||
import {
|
||||
createFileUpdateStateStore,
|
||||
createUpdateService,
|
||||
} from './main/runtime/update/update-service';
|
||||
import { updateSupportAssetsFromRelease } from './main/runtime/update/support-assets';
|
||||
import {
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler,
|
||||
createResolveActiveSubtitleSidebarSourceHandler,
|
||||
@@ -875,6 +910,8 @@ function stopStatsServer(): void {
|
||||
}
|
||||
|
||||
function requestAppQuit(): void {
|
||||
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
|
||||
appState.yomitanSettingsWindow = null;
|
||||
destroyStatsWindow();
|
||||
stopStatsServer();
|
||||
if (!forceQuitTimer) {
|
||||
@@ -1199,6 +1236,16 @@ const resolveWindowsMpvShortcutRuntimePaths = () =>
|
||||
appDataDir: app.getPath('appData'),
|
||||
desktopDir: app.getPath('desktop'),
|
||||
});
|
||||
const createCommandLineLauncherRuntimeOptions = () => ({
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
homeDir: os.homedir(),
|
||||
localAppData: process.env.LOCALAPPDATA,
|
||||
userProfile: process.env.USERPROFILE,
|
||||
cwd: process.cwd(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
appExePath: process.execPath,
|
||||
});
|
||||
syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
@@ -1274,6 +1321,32 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
shell.writeShortcutLink(shortcutPath, operation, details),
|
||||
});
|
||||
},
|
||||
detectCommandLineLauncher: () =>
|
||||
detectCommandLineLauncher(createCommandLineLauncherRuntimeOptions()),
|
||||
installBun: async () => {
|
||||
const snapshot = await installCommandLineBun(createCommandLineLauncherRuntimeOptions());
|
||||
return {
|
||||
ok: snapshot.status === 'ready',
|
||||
message:
|
||||
snapshot.message ??
|
||||
(snapshot.status === 'ready'
|
||||
? 'Bun is ready. Open a new terminal.'
|
||||
: 'Bun installation failed.'),
|
||||
};
|
||||
},
|
||||
installCommandLineLauncher: async () => {
|
||||
const snapshot = await installCommandLineLauncher(createCommandLineLauncherRuntimeOptions());
|
||||
const ok = snapshot.status === 'ready' || snapshot.status === 'installed_bun_missing';
|
||||
return {
|
||||
ok,
|
||||
installPath: snapshot.installPath,
|
||||
message:
|
||||
snapshot.message ??
|
||||
(ok
|
||||
? 'Command-line launcher installed. Open a new terminal.'
|
||||
: 'Command-line launcher installation failed.'),
|
||||
};
|
||||
},
|
||||
onStateChanged: (state) => {
|
||||
appState.firstRunSetupCompleted = state.status === 'completed';
|
||||
if (appTray) {
|
||||
@@ -2749,6 +2822,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
mpvExecutablePath,
|
||||
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
|
||||
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
||||
commandLineLauncher: snapshot.commandLineLauncher,
|
||||
message: firstRunSetupMessage,
|
||||
};
|
||||
},
|
||||
@@ -2784,6 +2858,16 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'install-bun') {
|
||||
const snapshot = await firstRunSetupService.installBun();
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'install-command-line-launcher') {
|
||||
const snapshot = await firstRunSetupService.installCommandLineLauncher();
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'open-yomitan-settings') {
|
||||
firstRunSetupMessage = openYomitanSettings()
|
||||
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
|
||||
@@ -3650,6 +3734,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
logWarning: (message) => appLogger.logWarning(message),
|
||||
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||
startConfigHotReload: () => configHotReloadRuntime.start(),
|
||||
shouldRefreshAnilistClientSecretState: () =>
|
||||
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
|
||||
refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretStateIfEnabled(options),
|
||||
failHandlers: {
|
||||
logError: (details) => logger.error(details),
|
||||
@@ -4518,6 +4604,92 @@ flushPendingMpvLogWrites = () => {
|
||||
void flushMpvLog();
|
||||
};
|
||||
|
||||
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
|
||||
let updateService: ReturnType<typeof createUpdateService> | null = null;
|
||||
|
||||
function getFetchForUpdater() {
|
||||
return globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
async function updateLauncherFromLatestRelease(
|
||||
launcherPath?: string,
|
||||
channel: UpdateChannel = getResolvedConfig().updates.channel,
|
||||
) {
|
||||
const fetchForUpdater = getFetchForUpdater();
|
||||
const release = await fetchLatestStableRelease({ fetch: fetchForUpdater, channel });
|
||||
if (!release) {
|
||||
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
|
||||
}
|
||||
const sumsAsset = findReleaseAsset(release, 'SHA256SUMS.txt');
|
||||
if (!sumsAsset) {
|
||||
return { status: 'missing-asset', message: 'Release has no SHA256SUMS.txt asset.' };
|
||||
}
|
||||
const sums = parseSha256Sums(
|
||||
await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url),
|
||||
);
|
||||
const launcherResult = await updateLauncherFromRelease({
|
||||
release,
|
||||
sha256Sums: sums,
|
||||
launcherPath,
|
||||
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
|
||||
});
|
||||
const supportResults = await updateSupportAssetsFromRelease({
|
||||
release,
|
||||
sha256Sums: sums,
|
||||
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
|
||||
});
|
||||
for (const result of supportResults) {
|
||||
if (result.status === 'protected' && result.command) {
|
||||
logger.warn(`Support assets update requires manual command: ${result.command}`);
|
||||
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
|
||||
logger.warn(`Support assets update skipped: ${result.message ?? result.status}`);
|
||||
}
|
||||
}
|
||||
return launcherResult;
|
||||
}
|
||||
|
||||
function getUpdateService() {
|
||||
if (updateService) return updateService;
|
||||
const appUpdater = createElectronAppUpdater({
|
||||
currentVersion: app.getVersion(),
|
||||
isPackaged: app.isPackaged,
|
||||
log: (message) => logger.info(message),
|
||||
getChannel: () => getResolvedConfig().updates.channel,
|
||||
});
|
||||
updateService = createUpdateService({
|
||||
getConfig: () => getResolvedConfig().updates,
|
||||
getCurrentVersion: () => app.getVersion(),
|
||||
now: () => Date.now(),
|
||||
readState: () => updateStateStore.readState(),
|
||||
writeState: (state) => updateStateStore.writeState(state),
|
||||
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
||||
fetchLatestStableRelease: (channel) =>
|
||||
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
|
||||
updateLauncher: (launcherPath, channel) =>
|
||||
updateLauncherFromLatestRelease(launcherPath, channel),
|
||||
showNoUpdateDialog: (version) =>
|
||||
showNoUpdateDialog((options) => dialog.showMessageBox(options), version),
|
||||
showUpdateAvailableDialog: (version) =>
|
||||
showUpdateAvailableDialog((options) => dialog.showMessageBox(options), version),
|
||||
showUpdateFailedDialog: (message) =>
|
||||
showUpdateFailedDialog((options) => dialog.showMessageBox(options), message),
|
||||
downloadAppUpdate: () => appUpdater.downloadUpdate(),
|
||||
showRestartDialog: () => showRestartDialog((options) => dialog.showMessageBox(options)),
|
||||
quitAndInstall: () => appUpdater.quitAndInstall(),
|
||||
notifyUpdateAvailable: (version) =>
|
||||
notifyUpdateAvailable(
|
||||
{ notificationType: getResolvedConfig().updates.notificationType, version },
|
||||
{
|
||||
showSystemNotification: (title, body) => showDesktopNotification(title, { body }),
|
||||
showOsdNotification: (message) => showMpvOsd(message),
|
||||
log: (message) => logger.warn(message),
|
||||
},
|
||||
),
|
||||
log: (message) => logger.warn(message),
|
||||
});
|
||||
return updateService;
|
||||
}
|
||||
|
||||
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
|
||||
cycleSecondarySubModeMainDeps: {
|
||||
getSecondarySubMode: () => appState.secondarySubMode,
|
||||
@@ -5173,6 +5345,14 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
|
||||
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
|
||||
runStatsCliCommand(argsFromCommand, source),
|
||||
runUpdateCommand: async (argsFromCommand: CliArgs, source: CliCommandSource) => {
|
||||
await runUpdateCliCommand(argsFromCommand, source, {
|
||||
checkForUpdates: (request) => getUpdateService().checkForUpdates(request),
|
||||
writeResponse: (responsePath, payload) =>
|
||||
writeUpdateCliCommandResponse(responsePath, payload),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
});
|
||||
},
|
||||
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||
@@ -5240,7 +5420,11 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
|
||||
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
|
||||
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
|
||||
shouldQuitOnWindowAllClosed: () => !appState.backgroundMode,
|
||||
shouldQuitOnWindowAllClosed: () =>
|
||||
shouldQuitOnWindowAllClosedForTrayState({
|
||||
backgroundMode: appState.backgroundMode,
|
||||
hasTray: Boolean(appTray),
|
||||
}),
|
||||
},
|
||||
createAppLifecycleRuntimeRunner: (params) => createAppLifecycleRuntimeRunner(params),
|
||||
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
|
||||
@@ -5283,6 +5467,12 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
|
||||
});
|
||||
|
||||
runAndApplyStartupState();
|
||||
void app.whenReady().then(() => {
|
||||
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
|
||||
return;
|
||||
}
|
||||
getUpdateService().startAutomaticChecks();
|
||||
});
|
||||
const startupModeFlags = getStartupModeFlags(appState.initialArgs);
|
||||
const shouldUseMinimalStartup = startupModeFlags.shouldUseMinimalStartup;
|
||||
const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup;
|
||||
@@ -5367,6 +5557,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||
openTexthookerInBrowser: () =>
|
||||
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
|
||||
showTexthookerPage: () => getResolvedConfig().texthooker.launchAtStartup !== false,
|
||||
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
@@ -5379,6 +5570,9 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
toggleJellyfinDiscovery: () =>
|
||||
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
openAnilistSetupWindow: () => openAnilistSetupWindow(),
|
||||
checkForUpdates: () => {
|
||||
void getUpdateService().checkForUpdates({ source: 'manual' });
|
||||
},
|
||||
quitApp: () => requestAppQuit(),
|
||||
},
|
||||
ensureTrayDeps: {
|
||||
@@ -5534,6 +5728,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
});
|
||||
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
|
||||
getYomitanExtension: () => appState.yomitanExt,
|
||||
getYomitanExtensionLoadInFlight: () => yomitanLoadInFlight,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {
|
||||
openYomitanSettingsWindow({
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
|
||||
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
|
||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
||||
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -118,6 +119,7 @@ function createCliCommandDepsFromContext(
|
||||
app: {
|
||||
stop: context.stopApp,
|
||||
hasMainWindow: context.hasMainWindow,
|
||||
runUpdateCommand: context.runUpdateCommand,
|
||||
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
||||
},
|
||||
dispatchSessionAction: context.dispatchSessionAction,
|
||||
|
||||
@@ -184,6 +184,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
app: {
|
||||
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
||||
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
||||
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
||||
};
|
||||
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
|
||||
@@ -362,6 +363,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
app: {
|
||||
stop: params.app.stop,
|
||||
hasMainWindow: params.app.hasMainWindow,
|
||||
runUpdateCommand: params.app.runUpdateCommand,
|
||||
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
||||
},
|
||||
dispatchSessionAction: params.dispatchSessionAction,
|
||||
|
||||
@@ -68,9 +68,12 @@ test('open yomitan settings main deps map async open callbacks', async () => {
|
||||
const calls: string[] = [];
|
||||
let currentWindow: unknown = null;
|
||||
const extension = { id: 'ext' };
|
||||
const startupLoad = Promise.resolve(extension);
|
||||
const yomitanSession = { id: 'session' };
|
||||
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => extension,
|
||||
getYomitanExtension: () => extension,
|
||||
getYomitanExtensionLoadInFlight: () => startupLoad,
|
||||
openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) =>
|
||||
calls.push(
|
||||
`open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`,
|
||||
@@ -86,6 +89,8 @@ test('open yomitan settings main deps map async open callbacks', async () => {
|
||||
})();
|
||||
|
||||
assert.equal(await deps.ensureYomitanExtensionLoaded(), extension);
|
||||
assert.equal(deps.getYomitanExtension?.(), extension);
|
||||
assert.equal(deps.getYomitanExtensionLoadInFlight?.(), startupLoad);
|
||||
assert.equal(deps.getExistingWindow(), null);
|
||||
deps.setWindow({ id: 'win' });
|
||||
deps.openYomitanSettingsWindow({
|
||||
|
||||
@@ -62,6 +62,8 @@ export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOpt
|
||||
|
||||
export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWindow>(deps: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<TYomitanExt | null>;
|
||||
getYomitanExtension?: () => TYomitanExt | null;
|
||||
getYomitanExtensionLoadInFlight?: () => Promise<unknown> | null;
|
||||
openYomitanSettingsWindow: (params: {
|
||||
yomitanExt: TYomitanExt;
|
||||
getExistingWindow: () => TWindow | null;
|
||||
@@ -77,6 +79,15 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
|
||||
}) {
|
||||
return () => ({
|
||||
ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(),
|
||||
...(deps.getYomitanExtension
|
||||
? { getYomitanExtension: () => deps.getYomitanExtension?.() ?? null }
|
||||
: {}),
|
||||
...(deps.getYomitanExtensionLoadInFlight
|
||||
? {
|
||||
getYomitanExtensionLoadInFlight: () =>
|
||||
deps.getYomitanExtensionLoadInFlight?.() ?? null,
|
||||
}
|
||||
: {}),
|
||||
openYomitanSettingsWindow: (params: {
|
||||
yomitanExt: TYomitanExt;
|
||||
getExistingWindow: () => TWindow | null;
|
||||
|
||||
@@ -63,6 +63,9 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
runUpdateCommand: async () => {
|
||||
calls.push('run-update');
|
||||
},
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
|
||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -94,6 +95,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
|
||||
runStatsCommand: deps.runStatsCommand,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
runUpdateCommand: deps.runUpdateCommand,
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
|
||||
@@ -71,6 +71,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
}),
|
||||
runStatsCommand: async () => {},
|
||||
runJellyfinCommand: async () => {},
|
||||
runUpdateCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
|
||||
@@ -92,6 +92,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('run-jellyfin');
|
||||
},
|
||||
runUpdateCommand: async () => {
|
||||
calls.push('run-update');
|
||||
},
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
calls.push('run-youtube-playback');
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { CliArgs, CliCommandSource } from '../../cli/args';
|
||||
import { resolveTexthookerWebsocketUrl } from '../../core/services/startup';
|
||||
import type { CliCommandContextFactoryDeps } from './cli-command-context';
|
||||
|
||||
@@ -53,6 +53,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
|
||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||
|
||||
openYomitanSettings: () => void;
|
||||
@@ -121,6 +122,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
|
||||
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
|
||||
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
|
||||
runUpdateCommand: (args: CliArgs, source: CliCommandSource) =>
|
||||
deps.runUpdateCommand(args, source),
|
||||
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
|
||||
|
||||
@@ -53,6 +53,7 @@ function createDeps() {
|
||||
}),
|
||||
runStatsCommand: async () => {},
|
||||
runJellyfinCommand: async () => {},
|
||||
runUpdateCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
|
||||
@@ -46,6 +46,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
setCharacterDictionarySelection?: CliCommandRuntimeServiceContext['setCharacterDictionarySelection'];
|
||||
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
|
||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
@@ -121,6 +122,7 @@ export function createCliCommandContext(
|
||||
})),
|
||||
runStatsCommand: deps.runStatsCommand,
|
||||
runJellyfinCommand: deps.runJellyfinCommand,
|
||||
runUpdateCommand: deps.runUpdateCommand,
|
||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
cycleSecondarySubMode: deps.cycleSecondarySubMode,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface RunCommandResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export type RunCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { timeoutMs?: number; env?: NodeJS.ProcessEnv },
|
||||
) => Promise<RunCommandResult>;
|
||||
|
||||
export type FsDeps = {
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
accessSync?: (candidate: string, mode?: number) => void;
|
||||
mkdirSync?: (candidate: string, options?: { recursive?: boolean }) => unknown;
|
||||
copyFileSync?: (from: string, to: string) => void;
|
||||
writeFileSync?: (candidate: string, content: string, encoding?: BufferEncoding) => void;
|
||||
readFileSync?: (candidate: string, encoding?: BufferEncoding) => string;
|
||||
chmodSync?: (candidate: string, mode: number) => void;
|
||||
};
|
||||
|
||||
export type CommonOptions = FsDeps & {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: Record<string, string | undefined>;
|
||||
homeDir?: string;
|
||||
cwd?: string;
|
||||
resourcesPath?: string;
|
||||
appExePath?: string;
|
||||
launcherResourcePath?: string;
|
||||
runCommand?: RunCommand;
|
||||
};
|
||||
|
||||
export type WindowsPathOptions = {
|
||||
localAppData?: string;
|
||||
userProfile?: string;
|
||||
getUserPath?: () => string;
|
||||
setUserPath?: (nextPath: string) => void | Promise<void>;
|
||||
broadcastEnvironmentChange?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function platformOf(options: CommonOptions): NodeJS.Platform {
|
||||
return options.platform ?? process.platform;
|
||||
}
|
||||
|
||||
export function envOf(options: CommonOptions): Record<string, string | undefined> {
|
||||
return options.env ?? process.env;
|
||||
}
|
||||
|
||||
export function pathModuleFor(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
|
||||
return platform === 'win32' ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
export function existsSyncOf(options: FsDeps): (candidate: string) => boolean {
|
||||
return options.existsSync ?? fs.existsSync;
|
||||
}
|
||||
|
||||
export function accessSyncOf(options: FsDeps): (candidate: string, mode?: number) => void {
|
||||
return options.accessSync ?? fs.accessSync;
|
||||
}
|
||||
|
||||
export function splitPath(value: string | undefined, platform: NodeJS.Platform): string[] {
|
||||
if (!value) return [];
|
||||
const delimiter = platform === 'win32' ? ';' : ':';
|
||||
return value
|
||||
.split(delimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizePathForCompare(
|
||||
candidate: string,
|
||||
platform: NodeJS.Platform,
|
||||
platformPath = pathModuleFor(platform),
|
||||
): string {
|
||||
const normalized = platformPath.normalize(candidate).replace(/[\\/]+$/, '');
|
||||
return platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
export function pathEntriesContain(
|
||||
entries: string[],
|
||||
dir: string,
|
||||
platform: NodeJS.Platform,
|
||||
): boolean {
|
||||
const normalizedDir = normalizePathForCompare(dir, platform);
|
||||
return entries.some((entry) => normalizePathForCompare(entry, platform) === normalizedDir);
|
||||
}
|
||||
|
||||
function isExecutableFile(candidate: string, options: CommonOptions): boolean {
|
||||
try {
|
||||
if (!existsSyncOf(options)(candidate)) return false;
|
||||
if (options.existsSync && !options.accessSync) return true;
|
||||
accessSyncOf(options)(candidate, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function findCommand(command: string, options: CommonOptions): string | null {
|
||||
const platform = platformOf(options);
|
||||
const platformPath = pathModuleFor(platform);
|
||||
const entries = splitPath(envOf(options).PATH, platform);
|
||||
const hasExtension = platformPath.extname(command) !== '';
|
||||
const extensions =
|
||||
platform === 'win32'
|
||||
? hasExtension
|
||||
? ['']
|
||||
: (envOf(options).PATHEXT?.split(';').filter(Boolean) ?? [
|
||||
'.exe',
|
||||
'.cmd',
|
||||
'.bat',
|
||||
'.EXE',
|
||||
'.CMD',
|
||||
'.BAT',
|
||||
])
|
||||
: [''];
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const extension of extensions) {
|
||||
const candidate = platformPath.join(entry, `${command}${extension}`);
|
||||
if (isExecutableFile(candidate, options)) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function tail(value: string, max = 1200): string {
|
||||
const clean = value.trim();
|
||||
return clean.length > max ? clean.slice(clean.length - max) : clean;
|
||||
}
|
||||
|
||||
export function failureMessage(result: RunCommandResult, fallback: string): string {
|
||||
const detail = tail(result.stderr || result.stdout);
|
||||
return detail ? `${fallback}: ${detail}` : fallback;
|
||||
}
|
||||
|
||||
function createDefaultRunCommand(): RunCommand {
|
||||
return (command, args, options = {}) =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
env: options.env ?? process.env,
|
||||
windowsHide: false,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill();
|
||||
}, options.timeoutMs ?? 15_000);
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout = tail(stdout + String(chunk), 4000);
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr = tail(stderr + String(chunk), 4000);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ exitCode: 1, stdout, stderr: error.message });
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ exitCode: code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getRunCommand(options: CommonOptions): RunCommand {
|
||||
return options.runCommand ?? createDefaultRunCommand();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
envOf,
|
||||
getRunCommand,
|
||||
pathEntriesContain,
|
||||
splitPath,
|
||||
type CommonOptions,
|
||||
type WindowsPathOptions,
|
||||
} from './command-line-launcher-deps';
|
||||
|
||||
const WINDOWS_PATH_MAX = 32767;
|
||||
|
||||
export function windowsLauncherPaths(options: CommonOptions & WindowsPathOptions): {
|
||||
binDir: string;
|
||||
installPath: string;
|
||||
} {
|
||||
const localAppData = options.localAppData ?? envOf(options).LOCALAPPDATA;
|
||||
const base = localAppData ?? path.win32.join(options.homeDir ?? os.homedir(), 'AppData', 'Local');
|
||||
const binDir = path.win32.join(base, 'SubMiner', 'bin');
|
||||
return { binDir, installPath: path.win32.join(binDir, 'subminer.cmd') };
|
||||
}
|
||||
|
||||
export function windowsShimContent(appExePath: string, launcherResourcePath: string): string {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
`set "SUBMINER_BINARY_PATH=${appExePath}"`,
|
||||
`bun "${launcherResourcePath}" %*`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
}
|
||||
|
||||
export function shimMatchesCurrentInstall(
|
||||
content: string,
|
||||
appExePath: string,
|
||||
launcherResourcePath: string,
|
||||
): boolean {
|
||||
const normalized = content.replaceAll('/', '\\').toLowerCase();
|
||||
return (
|
||||
normalized.includes(appExePath.replaceAll('/', '\\').toLowerCase()) &&
|
||||
normalized.includes(launcherResourcePath.replaceAll('/', '\\').toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserPath(options: CommonOptions & WindowsPathOptions): string {
|
||||
return options.getUserPath?.() ?? envOf(options).Path ?? envOf(options).PATH ?? '';
|
||||
}
|
||||
|
||||
async function setWindowsUserPath(
|
||||
options: CommonOptions & WindowsPathOptions,
|
||||
nextPath: string,
|
||||
) {
|
||||
if (options.setUserPath) {
|
||||
await options.setUserPath(nextPath);
|
||||
return;
|
||||
}
|
||||
const escaped = nextPath.replaceAll("'", "''");
|
||||
await getRunCommand(options)('powershell', [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
`[Environment]::SetEnvironmentVariable('Path', '${escaped}', 'User')`,
|
||||
]);
|
||||
}
|
||||
|
||||
async function broadcastEnvironmentChange(options: CommonOptions & WindowsPathOptions) {
|
||||
if (options.broadcastEnvironmentChange) {
|
||||
await options.broadcastEnvironmentChange();
|
||||
return;
|
||||
}
|
||||
await getRunCommand(options)('powershell', [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
"$signature='[DllImport(\"user32.dll\",SetLastError=true,CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd,uint Msg,UIntPtr wParam,string lParam,uint fuFlags,uint uTimeout,out UIntPtr lpdwResult);'; Add-Type -MemberDefinition $signature -Name NativeMethods -Namespace Win32; $result=[UIntPtr]::Zero; [Win32.NativeMethods]::SendMessageTimeout([IntPtr]0xffff,0x1A,[UIntPtr]::Zero,'Environment',2,5000,[ref]$result) | Out-Null",
|
||||
]);
|
||||
}
|
||||
|
||||
export async function appendWindowsUserPathDir(
|
||||
dir: string,
|
||||
options: CommonOptions & WindowsPathOptions,
|
||||
): Promise<string | null> {
|
||||
const current = getUserPath(options);
|
||||
const entries = splitPath(current, 'win32');
|
||||
if (pathEntriesContain(entries, dir, 'win32')) return null;
|
||||
const next = current.trim() ? `${current};${dir}` : dir;
|
||||
if (next.length > WINDOWS_PATH_MAX) {
|
||||
throw new Error('User PATH is too long to append the SubMiner launcher directory safely.');
|
||||
}
|
||||
await setWindowsUserPath(options, next);
|
||||
await broadcastEnvironmentChange(options);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function defaultBunRepairPath(options: CommonOptions & WindowsPathOptions): string {
|
||||
const userProfile = options.userProfile ?? envOf(options).USERPROFILE ?? options.homeDir ?? os.homedir();
|
||||
return path.win32.join(userProfile, '.bun', 'bin');
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
detectBun,
|
||||
detectLauncher,
|
||||
installLauncher,
|
||||
resolveBunInstallCommand,
|
||||
resolveLauncherInstallTarget,
|
||||
type BunSnapshot,
|
||||
} from './command-line-launcher';
|
||||
|
||||
function createBunSnapshot(status: BunSnapshot['status']): BunSnapshot {
|
||||
return {
|
||||
status,
|
||||
commandPath: status === 'ready' ? '/bin/bun' : null,
|
||||
version: status === 'ready' ? '1.3.0' : null,
|
||||
installMethod: null,
|
||||
installCommand: null,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
test('detectBun reports ready when bun --version succeeds on PATH', async () => {
|
||||
const snapshot = await detectBun({
|
||||
platform: 'linux',
|
||||
env: { PATH: '/usr/local/bin:/usr/bin' },
|
||||
existsSync: (candidate) => candidate === '/usr/local/bin/bun',
|
||||
accessSync: (candidate) => {
|
||||
if (candidate !== '/usr/local/bin/bun') throw new Error('not executable');
|
||||
},
|
||||
runCommand: async (command, args) => {
|
||||
assert.equal(command, '/usr/local/bin/bun');
|
||||
assert.deepEqual(args, ['--version']);
|
||||
return { exitCode: 0, stdout: '1.3.5\n', stderr: '' };
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(snapshot, {
|
||||
status: 'ready',
|
||||
commandPath: '/usr/local/bin/bun',
|
||||
version: '1.3.5',
|
||||
installMethod: null,
|
||||
installCommand: null,
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('detectBun reports missing with an install command when bun is absent', async () => {
|
||||
const snapshot = await detectBun({
|
||||
platform: 'linux',
|
||||
env: { PATH: '/usr/bin' },
|
||||
existsSync: () => false,
|
||||
accessSync: () => {
|
||||
throw new Error('missing');
|
||||
},
|
||||
runCommand: async () => ({ exitCode: 127, stdout: '', stderr: 'missing' }),
|
||||
});
|
||||
|
||||
assert.equal(snapshot.status, 'missing');
|
||||
assert.equal(snapshot.commandPath, null);
|
||||
assert.deepEqual(snapshot.installCommand, [
|
||||
'bash',
|
||||
'-lc',
|
||||
'curl -fsSL https://bun.com/install | bash',
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveBunInstallCommand prefers winget on Windows', () => {
|
||||
assert.deepEqual(
|
||||
resolveBunInstallCommand({
|
||||
platform: 'win32',
|
||||
env: { PATH: 'C:\\Tools' },
|
||||
existsSync: (candidate) => candidate === 'C:\\Tools\\winget.exe',
|
||||
}),
|
||||
[
|
||||
'C:\\Tools\\winget.exe',
|
||||
'install',
|
||||
'--id',
|
||||
'Oven-sh.Bun',
|
||||
'--exact',
|
||||
'--accept-package-agreements',
|
||||
'--accept-source-agreements',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveBunInstallCommand falls back to scoop on Windows before official installer', () => {
|
||||
assert.deepEqual(
|
||||
resolveBunInstallCommand({
|
||||
platform: 'win32',
|
||||
env: { PATH: 'C:\\Tools' },
|
||||
existsSync: (candidate) => candidate === 'C:\\Tools\\scoop.cmd',
|
||||
}),
|
||||
['C:\\Tools\\scoop.cmd', 'install', 'bun'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveBunInstallCommand uses Homebrew on macOS when available', () => {
|
||||
assert.deepEqual(
|
||||
resolveBunInstallCommand({
|
||||
platform: 'darwin',
|
||||
env: { PATH: '/opt/homebrew/bin:/usr/bin' },
|
||||
existsSync: (candidate) => candidate === '/opt/homebrew/bin/brew',
|
||||
accessSync: (candidate) => {
|
||||
if (candidate !== '/opt/homebrew/bin/brew') throw new Error('not executable');
|
||||
},
|
||||
}),
|
||||
['/opt/homebrew/bin/brew', 'install', 'bun'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveLauncherInstallTarget prefers writable user bin on Linux', async () => {
|
||||
const target = await resolveLauncherInstallTarget({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
env: { PATH: '/usr/bin:/home/tester/.local/bin:/tmp/bin' },
|
||||
existsSync: (candidate) =>
|
||||
candidate === '/usr/bin' ||
|
||||
candidate === '/home/tester/.local/bin' ||
|
||||
candidate === '/tmp/bin',
|
||||
accessSync: (candidate) => {
|
||||
if (candidate !== '/home/tester/.local/bin') throw new Error('not writable');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(target.status, 'not_installed');
|
||||
assert.equal(target.pathDir, '/home/tester/.local/bin');
|
||||
assert.equal(target.installPath, '/home/tester/.local/bin/subminer');
|
||||
});
|
||||
|
||||
test('resolveLauncherInstallTarget returns not_installable without writable PATH dirs', async () => {
|
||||
const target = await resolveLauncherInstallTarget({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
env: { PATH: '/usr/bin' },
|
||||
existsSync: (candidate) => candidate === '/usr/bin',
|
||||
accessSync: () => {
|
||||
throw new Error('not writable');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(target.status, 'not_installable');
|
||||
assert.equal(target.installPath, null);
|
||||
});
|
||||
|
||||
test('installLauncher writes Windows cmd shim and appends user PATH once', async () => {
|
||||
const files = new Map<string, string>();
|
||||
const dirs = new Set<string>();
|
||||
const launcherResource = 'C:\\Apps\\SubMiner\\resources\\launcher\\subminer';
|
||||
files.set(launcherResource, 'launcher');
|
||||
let userPath = 'C:\\Tools;C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin';
|
||||
let setPathCalls = 0;
|
||||
|
||||
const snapshot = await installLauncher({
|
||||
platform: 'win32',
|
||||
localAppData: 'C:\\Users\\tester\\AppData\\Local',
|
||||
appExePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||
launcherResourcePath: launcherResource,
|
||||
env: { PATH: userPath },
|
||||
existsSync: (candidate) => files.has(candidate) || dirs.has(candidate),
|
||||
mkdirSync: (candidate) => dirs.add(candidate),
|
||||
readFileSync: (candidate) => files.get(candidate) ?? '',
|
||||
writeFileSync: (candidate, content) => files.set(candidate, content),
|
||||
getUserPath: () => userPath,
|
||||
setUserPath: (next) => {
|
||||
setPathCalls += 1;
|
||||
userPath = next;
|
||||
},
|
||||
broadcastEnvironmentChange: () => undefined,
|
||||
runCommand: async (command, args) => {
|
||||
if (command.endsWith('subminer.cmd') && args[0] === '--help') {
|
||||
return { exitCode: 0, stdout: 'help', stderr: '' };
|
||||
}
|
||||
return { exitCode: 1, stdout: '', stderr: 'unexpected' };
|
||||
},
|
||||
bunSnapshot: createBunSnapshot('ready'),
|
||||
});
|
||||
|
||||
const shimPath = 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin\\subminer.cmd';
|
||||
assert.equal(setPathCalls, 0);
|
||||
assert.match(
|
||||
files.get(shimPath) ?? '',
|
||||
/set "SUBMINER_BINARY_PATH=C:\\Apps\\SubMiner\\SubMiner\.exe"/,
|
||||
);
|
||||
assert.match(
|
||||
files.get(shimPath) ?? '',
|
||||
/bun "C:\\Apps\\SubMiner\\resources\\launcher\\subminer" %\*/,
|
||||
);
|
||||
assert.equal(snapshot.status, 'ready');
|
||||
});
|
||||
|
||||
test('detectLauncher reports shadowed when another subminer appears earlier on PATH', async () => {
|
||||
const snapshot = await detectLauncher({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
env: { PATH: '/tmp/bin:/home/tester/.local/bin' },
|
||||
existsSync: (candidate) =>
|
||||
candidate === '/tmp/bin' ||
|
||||
candidate === '/home/tester/.local/bin' ||
|
||||
candidate === '/tmp/bin/subminer' ||
|
||||
candidate === '/home/tester/.local/bin/subminer',
|
||||
accessSync: () => undefined,
|
||||
bunSnapshot: createBunSnapshot('ready'),
|
||||
});
|
||||
|
||||
assert.equal(snapshot.status, 'shadowed');
|
||||
assert.equal(snapshot.shadowedBy, '/tmp/bin/subminer');
|
||||
assert.equal(snapshot.installPath, '/home/tester/.local/bin/subminer');
|
||||
});
|
||||
|
||||
test('detectLauncher reports installed_bun_missing when launcher exists but bun is missing', async () => {
|
||||
const snapshot = await detectLauncher({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
env: { PATH: '/home/tester/.local/bin' },
|
||||
existsSync: (candidate) =>
|
||||
candidate === '/home/tester/.local/bin' || candidate === '/home/tester/.local/bin/subminer',
|
||||
accessSync: () => undefined,
|
||||
bunSnapshot: createBunSnapshot('missing'),
|
||||
});
|
||||
|
||||
assert.equal(snapshot.status, 'installed_bun_missing');
|
||||
});
|
||||
|
||||
test('detectLauncher treats stale Windows shim as not installed', async () => {
|
||||
const shimPath = 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin\\subminer.cmd';
|
||||
const snapshot = await detectLauncher({
|
||||
platform: 'win32',
|
||||
localAppData: 'C:\\Users\\tester\\AppData\\Local',
|
||||
appExePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||
launcherResourcePath: 'C:\\Apps\\SubMiner\\resources\\launcher\\subminer',
|
||||
env: { PATH: 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin' },
|
||||
existsSync: (candidate) =>
|
||||
candidate === shimPath || candidate === 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin',
|
||||
readFileSync: () =>
|
||||
'@echo off\nset "SUBMINER_BINARY_PATH=C:\\Old\\SubMiner.exe"\nbun "C:\\Old\\launcher\\subminer" %*\n',
|
||||
bunSnapshot: createBunSnapshot('ready'),
|
||||
});
|
||||
|
||||
assert.equal(snapshot.status, 'not_installed');
|
||||
assert.match(snapshot.message ?? '', /previous SubMiner install/);
|
||||
});
|
||||
|
||||
test('installLauncher copies packaged launcher and chmods on POSIX', async () => {
|
||||
const files = new Map<string, string>([['/resources/launcher/subminer', 'launcher']]);
|
||||
const modes = new Map<string, number>();
|
||||
|
||||
const snapshot = await installLauncher({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
env: { PATH: '/home/tester/.local/bin' },
|
||||
launcherResourcePath: '/resources/launcher/subminer',
|
||||
existsSync: (candidate) => files.has(candidate) || candidate === '/home/tester/.local/bin',
|
||||
accessSync: () => undefined,
|
||||
copyFileSync: (from, to) => files.set(to, files.get(from) ?? ''),
|
||||
chmodSync: (candidate, mode) => modes.set(candidate, mode),
|
||||
runCommand: async (command, args) => {
|
||||
assert.equal(command, '/home/tester/.local/bin/subminer');
|
||||
assert.deepEqual(args, ['--help']);
|
||||
return { exitCode: 0, stdout: 'help', stderr: '' };
|
||||
},
|
||||
bunSnapshot: createBunSnapshot('ready'),
|
||||
});
|
||||
|
||||
assert.equal(files.get('/home/tester/.local/bin/subminer'), 'launcher');
|
||||
assert.equal(modes.get('/home/tester/.local/bin/subminer'), 0o755);
|
||||
assert.equal(snapshot.status, 'ready');
|
||||
});
|
||||
@@ -0,0 +1,444 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
accessSyncOf,
|
||||
envOf,
|
||||
existsSyncOf,
|
||||
failureMessage,
|
||||
findCommand,
|
||||
getRunCommand,
|
||||
normalizePathForCompare,
|
||||
pathEntriesContain,
|
||||
pathModuleFor,
|
||||
platformOf,
|
||||
splitPath,
|
||||
type CommonOptions,
|
||||
type WindowsPathOptions,
|
||||
} from './command-line-launcher-deps';
|
||||
import {
|
||||
appendWindowsUserPathDir,
|
||||
defaultBunRepairPath,
|
||||
shimMatchesCurrentInstall,
|
||||
windowsLauncherPaths,
|
||||
windowsShimContent,
|
||||
} from './command-line-launcher-windows';
|
||||
|
||||
export type { RunCommand, RunCommandResult } from './command-line-launcher-deps';
|
||||
|
||||
export type ToolStatus = 'ready' | 'missing' | 'installing' | 'failed';
|
||||
|
||||
export type LauncherInstallStatus =
|
||||
| 'ready'
|
||||
| 'installed_bun_missing'
|
||||
| 'not_installed'
|
||||
| 'not_on_path'
|
||||
| 'shadowed'
|
||||
| 'not_installable'
|
||||
| 'failed';
|
||||
|
||||
export interface BunSnapshot {
|
||||
status: ToolStatus;
|
||||
commandPath: string | null;
|
||||
version: string | null;
|
||||
installMethod: 'winget' | 'scoop' | 'homebrew' | 'official-script' | null;
|
||||
installCommand: string[] | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface LauncherSnapshot {
|
||||
status: LauncherInstallStatus;
|
||||
commandPath: string | null;
|
||||
installPath: string | null;
|
||||
pathDir: string | null;
|
||||
shadowedBy: string | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface CommandLineLauncherSnapshot {
|
||||
supported: boolean;
|
||||
bun: BunSnapshot;
|
||||
launcher: LauncherSnapshot;
|
||||
}
|
||||
|
||||
const BUN_OFFICIAL_POSIX_COMMAND = ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'];
|
||||
const BUN_OFFICIAL_WINDOWS_COMMAND = [
|
||||
'powershell',
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
'irm bun.sh/install.ps1 | iex',
|
||||
];
|
||||
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const COMMAND_TIMEOUT_MS = 15 * 1000;
|
||||
|
||||
function installMethodForCommand(
|
||||
command: string[] | null,
|
||||
): BunSnapshot['installMethod'] {
|
||||
if (!command) return null;
|
||||
const executablePath = command[0];
|
||||
if (!executablePath) return null;
|
||||
const executable = path.win32.basename(executablePath).toLowerCase();
|
||||
if (executable === 'winget.exe') return 'winget';
|
||||
if (executable === 'scoop.cmd') return 'scoop';
|
||||
if (executable === 'brew') return 'homebrew';
|
||||
return 'official-script';
|
||||
}
|
||||
|
||||
export function resolveBunInstallCommand(options: CommonOptions = {}): BunSnapshot['installCommand'] {
|
||||
const platform = platformOf(options);
|
||||
if (platform === 'win32') {
|
||||
const winget = findCommand('winget.exe', options);
|
||||
if (winget) {
|
||||
return [
|
||||
winget,
|
||||
'install',
|
||||
'--id',
|
||||
'Oven-sh.Bun',
|
||||
'--exact',
|
||||
'--accept-package-agreements',
|
||||
'--accept-source-agreements',
|
||||
];
|
||||
}
|
||||
const scoop = findCommand('scoop.cmd', options);
|
||||
if (scoop) return [scoop, 'install', 'bun'];
|
||||
return BUN_OFFICIAL_WINDOWS_COMMAND;
|
||||
}
|
||||
|
||||
const brew = findCommand('brew', options);
|
||||
if (platform === 'darwin' && brew) return [brew, 'install', 'bun'];
|
||||
if (platform === 'linux' && brew) return [brew, 'install', 'bun'];
|
||||
return BUN_OFFICIAL_POSIX_COMMAND;
|
||||
}
|
||||
|
||||
export async function detectBun(options: CommonOptions = {}): Promise<BunSnapshot> {
|
||||
const bunPath = findCommand('bun', options);
|
||||
const installCommand = resolveBunInstallCommand(options);
|
||||
if (!bunPath) {
|
||||
return {
|
||||
status: 'missing',
|
||||
commandPath: null,
|
||||
version: null,
|
||||
installMethod: installMethodForCommand(installCommand),
|
||||
installCommand,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await getRunCommand(options)(bunPath, ['--version'], {
|
||||
timeoutMs: COMMAND_TIMEOUT_MS,
|
||||
env: envOf(options) as NodeJS.ProcessEnv,
|
||||
});
|
||||
if (result.exitCode === 0) {
|
||||
return {
|
||||
status: 'ready',
|
||||
commandPath: bunPath,
|
||||
version: result.stdout.trim() || null,
|
||||
installMethod: null,
|
||||
installCommand: null,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'failed',
|
||||
commandPath: bunPath,
|
||||
version: null,
|
||||
installMethod: installMethodForCommand(installCommand),
|
||||
installCommand,
|
||||
message: failureMessage(result, 'bun --version failed'),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLauncherResourcePath(options: CommonOptions): string {
|
||||
const platformPath = pathModuleFor(platformOf(options));
|
||||
if (options.launcherResourcePath) return options.launcherResourcePath;
|
||||
const resourcesPath = options.resourcesPath ?? (process as typeof process & { resourcesPath?: string }).resourcesPath;
|
||||
const packaged = resourcesPath ? platformPath.join(resourcesPath, 'launcher', 'subminer') : null;
|
||||
if (packaged && existsSyncOf(options)(packaged)) return packaged;
|
||||
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer');
|
||||
}
|
||||
|
||||
function isWritableDir(candidate: string, options: CommonOptions): boolean {
|
||||
try {
|
||||
if (!existsSyncOf(options)(candidate)) return false;
|
||||
accessSyncOf(options)(candidate, fs.constants.W_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function collectPathDirs(options: CommonOptions): string[] {
|
||||
const platform = platformOf(options);
|
||||
const dirs: string[] = [];
|
||||
const add = (dir: string) => {
|
||||
if (!pathEntriesContain(dirs, dir, platform)) dirs.push(dir);
|
||||
};
|
||||
splitPath(envOf(options).PATH, platform).forEach(add);
|
||||
return dirs;
|
||||
}
|
||||
|
||||
export async function resolveLauncherInstallTarget(
|
||||
options: CommonOptions & WindowsPathOptions = {},
|
||||
): Promise<LauncherSnapshot> {
|
||||
const platform = platformOf(options);
|
||||
if (platform === 'win32') {
|
||||
const { binDir, installPath } = windowsLauncherPaths(options);
|
||||
return {
|
||||
status: existsSyncOf(options)(installPath) ? 'ready' : 'not_installed',
|
||||
commandPath: existsSyncOf(options)(installPath) ? installPath : null,
|
||||
installPath,
|
||||
pathDir: binDir,
|
||||
shadowedBy: null,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
const homeDir = options.homeDir ?? os.homedir();
|
||||
const pathDirs = collectPathDirs(options);
|
||||
const preferred =
|
||||
platform === 'darwin'
|
||||
? [
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
path.posix.join(homeDir, '.local', 'bin'),
|
||||
path.posix.join(homeDir, 'bin'),
|
||||
]
|
||||
: [path.posix.join(homeDir, '.local', 'bin'), path.posix.join(homeDir, 'bin'), '/usr/local/bin'];
|
||||
const candidates = [...preferred, ...pathDirs].filter((dir, index, all) =>
|
||||
all.findIndex((other) => normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform)) === index,
|
||||
);
|
||||
const selected = candidates.find((dir) => pathEntriesContain(pathDirs, dir, platform) && isWritableDir(dir, options));
|
||||
if (!selected) {
|
||||
return {
|
||||
status: 'not_installable',
|
||||
commandPath: null,
|
||||
installPath: null,
|
||||
pathDir: null,
|
||||
shadowedBy: null,
|
||||
message: 'No writable directory was found on your command-line PATH.',
|
||||
};
|
||||
}
|
||||
const installPath = path.posix.join(selected, 'subminer');
|
||||
return {
|
||||
status: existsSyncOf(options)(installPath) ? 'ready' : 'not_installed',
|
||||
commandPath: existsSyncOf(options)(installPath) ? installPath : null,
|
||||
installPath,
|
||||
pathDir: selected,
|
||||
shadowedBy: null,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectLauncher(
|
||||
options: CommonOptions & WindowsPathOptions & { bunSnapshot?: BunSnapshot } = {},
|
||||
): Promise<LauncherSnapshot> {
|
||||
const platform = platformOf(options);
|
||||
const target = await resolveLauncherInstallTarget(options);
|
||||
if (target.status === 'not_installable') return target;
|
||||
const expectedPath = target.installPath;
|
||||
if (!expectedPath) return target;
|
||||
const platformPath = pathModuleFor(platform);
|
||||
const launcherResourcePath = resolveLauncherResourcePath(options);
|
||||
const appExePath = options.appExePath ?? process.execPath;
|
||||
|
||||
if (platform === 'win32' && existsSyncOf(options)(expectedPath)) {
|
||||
const content = String((options.readFileSync ?? fs.readFileSync)(expectedPath, 'utf8'));
|
||||
if (!shimMatchesCurrentInstall(content, appExePath, launcherResourcePath)) {
|
||||
return {
|
||||
...target,
|
||||
status: 'not_installed',
|
||||
commandPath: null,
|
||||
message: 'Installed launcher points at a previous SubMiner install; reinstall to refresh.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const commandPath = findCommand('subminer', options);
|
||||
const expectedNormalized = normalizePathForCompare(expectedPath, platform, platformPath);
|
||||
if (commandPath && normalizePathForCompare(commandPath, platform, platformPath) !== expectedNormalized) {
|
||||
return { ...target, status: 'shadowed', commandPath: expectedPath, shadowedBy: commandPath };
|
||||
}
|
||||
if (!existsSyncOf(options)(expectedPath)) return { ...target, status: 'not_installed', commandPath: null };
|
||||
if (!commandPath) {
|
||||
return {
|
||||
...target,
|
||||
status: 'not_on_path',
|
||||
commandPath: expectedPath,
|
||||
message: 'Launcher exists but its directory is not on PATH.',
|
||||
};
|
||||
}
|
||||
|
||||
const bunSnapshot = options.bunSnapshot ?? (await detectBun(options));
|
||||
if (bunSnapshot.status !== 'ready') {
|
||||
return {
|
||||
...target,
|
||||
status: 'installed_bun_missing',
|
||||
commandPath,
|
||||
message: 'Launcher is installed, but Bun is missing. Install Bun, then open a new terminal.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await getRunCommand(options)(commandPath, ['--help'], {
|
||||
timeoutMs: COMMAND_TIMEOUT_MS,
|
||||
env: envOf(options) as NodeJS.ProcessEnv,
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
return {
|
||||
...target,
|
||||
status: 'failed',
|
||||
commandPath,
|
||||
message: failureMessage(result, 'subminer --help failed'),
|
||||
};
|
||||
}
|
||||
return { ...target, status: 'ready', commandPath, message: null };
|
||||
}
|
||||
|
||||
export async function installLauncher(
|
||||
options: CommonOptions & WindowsPathOptions & { bunSnapshot?: BunSnapshot } = {},
|
||||
): Promise<LauncherSnapshot> {
|
||||
const platform = platformOf(options);
|
||||
const target = await resolveLauncherInstallTarget(options);
|
||||
if (!target.installPath || !target.pathDir) return target;
|
||||
const launcherResourcePath = resolveLauncherResourcePath(options);
|
||||
if (!existsSyncOf(options)(launcherResourcePath)) {
|
||||
return {
|
||||
...target,
|
||||
status: 'failed',
|
||||
message: `Packaged launcher resource is missing: ${launcherResourcePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
|
||||
(options.writeFileSync ?? fs.writeFileSync)(
|
||||
target.installPath,
|
||||
windowsShimContent(options.appExePath ?? process.execPath, launcherResourcePath),
|
||||
'utf8',
|
||||
);
|
||||
try {
|
||||
const nextPath = await appendWindowsUserPathDir(target.pathDir, options);
|
||||
if (nextPath && options.env) {
|
||||
options.env.PATH = nextPath;
|
||||
options.env.Path = nextPath;
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
...target,
|
||||
status: 'failed',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
(options.copyFileSync ?? fs.copyFileSync)(launcherResourcePath, target.installPath);
|
||||
(options.chmodSync ?? fs.chmodSync)(target.installPath, 0o755);
|
||||
}
|
||||
return detectLauncher(options);
|
||||
}
|
||||
|
||||
export async function installBun(
|
||||
options: CommonOptions & WindowsPathOptions = {},
|
||||
): Promise<BunSnapshot> {
|
||||
const platform = platformOf(options);
|
||||
if (platform === 'win32') {
|
||||
const bunDir = defaultBunRepairPath(options);
|
||||
const bunExe = path.win32.join(bunDir, 'bun.exe');
|
||||
if (existsSyncOf(options)(bunExe) && !findCommand('bun.exe', options)) {
|
||||
try {
|
||||
await appendWindowsUserPathDir(bunDir, options);
|
||||
return {
|
||||
status: 'ready',
|
||||
commandPath: bunExe,
|
||||
version: null,
|
||||
installMethod: null,
|
||||
installCommand: null,
|
||||
message: 'Bun PATH repaired. Open a new terminal.',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'failed',
|
||||
commandPath: bunExe,
|
||||
version: null,
|
||||
installMethod: null,
|
||||
installCommand: null,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const installCommand = resolveBunInstallCommand(options);
|
||||
if (!installCommand || installCommand.length === 0) {
|
||||
return {
|
||||
status: 'missing',
|
||||
commandPath: null,
|
||||
version: null,
|
||||
installMethod: null,
|
||||
installCommand: null,
|
||||
message: 'No Bun install command is available for this platform.',
|
||||
};
|
||||
}
|
||||
|
||||
const command = installCommand[0]!;
|
||||
const args = installCommand.slice(1);
|
||||
const result = await getRunCommand(options)(command, args, {
|
||||
timeoutMs: INSTALL_TIMEOUT_MS,
|
||||
env: envOf(options) as NodeJS.ProcessEnv,
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
return {
|
||||
status: 'failed',
|
||||
commandPath: null,
|
||||
version: null,
|
||||
installMethod: installMethodForCommand(installCommand),
|
||||
installCommand,
|
||||
message: failureMessage(result, 'Bun install failed'),
|
||||
};
|
||||
}
|
||||
|
||||
const detected = await detectBun(options);
|
||||
if (detected.status === 'ready') {
|
||||
return { ...detected, message: 'Bun installed. Open a new terminal.' };
|
||||
}
|
||||
return {
|
||||
...detected,
|
||||
status: 'missing',
|
||||
message:
|
||||
platform === 'win32'
|
||||
? 'Bun installed, but this process cannot see it on PATH yet. Open a new terminal.'
|
||||
: 'Bun installed, but is not on PATH for this shell. Add ~/.bun/bin to PATH if needed.',
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectCommandLineLauncher(
|
||||
options: CommonOptions & WindowsPathOptions = {},
|
||||
): Promise<CommandLineLauncherSnapshot> {
|
||||
const platform = platformOf(options);
|
||||
const supported = platform === 'win32' || platform === 'linux' || platform === 'darwin';
|
||||
if (!supported) {
|
||||
return {
|
||||
supported: false,
|
||||
bun: {
|
||||
status: 'missing',
|
||||
commandPath: null,
|
||||
version: null,
|
||||
installMethod: null,
|
||||
installCommand: null,
|
||||
message: 'Command-line launcher setup is not supported on this platform.',
|
||||
},
|
||||
launcher: {
|
||||
status: 'not_installable',
|
||||
commandPath: null,
|
||||
installPath: null,
|
||||
pathDir: null,
|
||||
shadowedBy: null,
|
||||
message: 'Command-line launcher setup is not supported on this platform.',
|
||||
},
|
||||
};
|
||||
}
|
||||
const bun = await detectBun(options);
|
||||
const launcher = await detectLauncher({ ...options, bunSnapshot: bun });
|
||||
return { supported, bun, launcher };
|
||||
}
|
||||
@@ -47,6 +47,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
}),
|
||||
runJellyfinCommand: async () => {},
|
||||
runStatsCommand: async () => {},
|
||||
runUpdateCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
|
||||
@@ -5,6 +5,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup } from './first-run-setup-service';
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
|
||||
|
||||
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-'));
|
||||
@@ -90,6 +91,31 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
};
|
||||
}
|
||||
|
||||
function createCommandLineLauncherSnapshot(
|
||||
overrides: Partial<CommandLineLauncherSnapshot> = {},
|
||||
): CommandLineLauncherSnapshot {
|
||||
return {
|
||||
supported: true,
|
||||
bun: {
|
||||
status: 'missing',
|
||||
commandPath: null,
|
||||
version: null,
|
||||
installMethod: 'official-script',
|
||||
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
|
||||
message: null,
|
||||
},
|
||||
launcher: {
|
||||
status: 'not_installed',
|
||||
commandPath: null,
|
||||
installPath: '/home/tester/.local/bin/subminer',
|
||||
pathDir: '/home/tester/.local/bin',
|
||||
shadowedBy: null,
|
||||
message: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
|
||||
@@ -514,6 +540,141 @@ test('setup service persists Windows mpv shortcut preferences and status with on
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service snapshot includes command-line launcher status', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
|
||||
const commandLineLauncher = createCommandLineLauncherSnapshot({
|
||||
bun: {
|
||||
status: 'ready',
|
||||
commandPath: '/usr/local/bin/bun',
|
||||
version: '1.3.5',
|
||||
installMethod: null,
|
||||
installCommand: null,
|
||||
message: null,
|
||||
},
|
||||
});
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
detectPluginInstalled: () => true,
|
||||
detectCommandLineLauncher: async () => commandLineLauncher,
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const snapshot = await service.refreshStatus();
|
||||
assert.deepEqual(snapshot.commandLineLauncher, commandLineLauncher);
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service installBun persists installed and failed status', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
let installOk = true;
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => true,
|
||||
detectCommandLineLauncher: async () => createCommandLineLauncherSnapshot(),
|
||||
installBun: async () => ({
|
||||
ok: installOk,
|
||||
message: installOk ? 'Bun installed. Open a new terminal.' : 'Bun install failed.',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const installed = await service.installBun();
|
||||
assert.equal(installed.state.bunInstallStatus, 'installed');
|
||||
assert.equal(installed.canFinish, true);
|
||||
assert.equal(installed.message, 'Bun installed. Open a new terminal.');
|
||||
|
||||
installOk = false;
|
||||
const failed = await service.installBun();
|
||||
assert.equal(failed.state.bunInstallStatus, 'failed');
|
||||
assert.equal(failed.canFinish, true);
|
||||
assert.equal(failed.message, 'Bun install failed.');
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service installCommandLineLauncher persists status and path', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
let installOk = true;
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => true,
|
||||
detectCommandLineLauncher: async () => createCommandLineLauncherSnapshot(),
|
||||
installCommandLineLauncher: async () => ({
|
||||
ok: installOk,
|
||||
installPath: installOk ? '/home/tester/.local/bin/subminer' : null,
|
||||
message: installOk ? 'Launcher installed.' : 'Launcher install failed.',
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const installed = await service.installCommandLineLauncher();
|
||||
assert.equal(installed.state.launcherInstallStatus, 'installed');
|
||||
assert.equal(installed.state.launcherInstallPath, '/home/tester/.local/bin/subminer');
|
||||
assert.equal(installed.canFinish, true);
|
||||
|
||||
installOk = false;
|
||||
const failed = await service.installCommandLineLauncher();
|
||||
assert.equal(failed.state.launcherInstallStatus, 'failed');
|
||||
assert.equal(failed.state.launcherInstallPath, null);
|
||||
assert.equal(failed.canFinish, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('setup completion is unaffected by missing or failed command-line launcher setup', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
const service = createFirstRunSetupService({
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
detectPluginInstalled: () => true,
|
||||
detectCommandLineLauncher: async () =>
|
||||
createCommandLineLauncherSnapshot({
|
||||
bun: {
|
||||
status: 'failed',
|
||||
commandPath: null,
|
||||
version: null,
|
||||
installMethod: 'official-script',
|
||||
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
|
||||
message: 'Bun install failed.',
|
||||
},
|
||||
launcher: {
|
||||
status: 'failed',
|
||||
commandPath: null,
|
||||
installPath: '/home/tester/.local/bin/subminer',
|
||||
pathDir: '/home/tester/.local/bin',
|
||||
shadowedBy: null,
|
||||
message: 'Launcher install failed.',
|
||||
},
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const initial = await service.ensureSetupStateInitialized();
|
||||
assert.equal(initial.canFinish, true);
|
||||
|
||||
const completed = await service.markSetupCompleted();
|
||||
assert.equal(completed.state.status, 'completed');
|
||||
assert.equal(completed.canFinish, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service removes legacy mpv plugin candidates and refreshes detection', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
InstalledFirstRunPluginCandidate,
|
||||
LegacyMpvPluginRemovalResult,
|
||||
} from './first-run-setup-plugin';
|
||||
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
|
||||
|
||||
export interface SetupWindowsMpvShortcutSnapshot {
|
||||
supported: boolean;
|
||||
@@ -35,6 +36,7 @@ export interface SetupStatusSnapshot {
|
||||
pluginInstallPathSummary: string | null;
|
||||
legacyMpvPluginPaths: string[];
|
||||
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
|
||||
commandLineLauncher: CommandLineLauncherSnapshot;
|
||||
message: string | null;
|
||||
state: SetupState;
|
||||
}
|
||||
@@ -58,6 +60,8 @@ export interface FirstRunSetupService {
|
||||
startMenuEnabled: boolean;
|
||||
desktopEnabled: boolean;
|
||||
}) => Promise<SetupStatusSnapshot>;
|
||||
installBun: () => Promise<SetupStatusSnapshot>;
|
||||
installCommandLineLauncher: () => Promise<SetupStatusSnapshot>;
|
||||
isSetupCompleted: () => boolean;
|
||||
}
|
||||
|
||||
@@ -172,6 +176,28 @@ function isYomitanSetupSatisfied(options: {
|
||||
return options.externalYomitanConfigured || options.dictionaryCount >= 1;
|
||||
}
|
||||
|
||||
function createUnsupportedCommandLineLauncherSnapshot(): CommandLineLauncherSnapshot {
|
||||
return {
|
||||
supported: false,
|
||||
bun: {
|
||||
status: 'missing',
|
||||
commandPath: null,
|
||||
version: null,
|
||||
installMethod: null,
|
||||
installCommand: null,
|
||||
message: 'Command-line launcher setup is unavailable in this runtime.',
|
||||
},
|
||||
launcher: {
|
||||
status: 'not_installable',
|
||||
commandPath: null,
|
||||
installPath: null,
|
||||
pathDir: null,
|
||||
shadowedBy: null,
|
||||
message: 'Command-line launcher setup is unavailable in this runtime.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getFirstRunSetupCompletionMessage(snapshot: {
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
@@ -235,6 +261,15 @@ export function createFirstRunSetupService(deps: {
|
||||
startMenuEnabled: boolean;
|
||||
desktopEnabled: boolean;
|
||||
}) => Promise<{ ok: boolean; status: SetupWindowsMpvShortcutInstallStatus; message: string }>;
|
||||
detectCommandLineLauncher?: () =>
|
||||
| CommandLineLauncherSnapshot
|
||||
| Promise<CommandLineLauncherSnapshot>;
|
||||
installBun?: () => Promise<{ ok: boolean; message: string }>;
|
||||
installCommandLineLauncher?: () => Promise<{
|
||||
ok: boolean;
|
||||
installPath: string | null;
|
||||
message: string;
|
||||
}>;
|
||||
onStateChanged?: (state: SetupState) => void;
|
||||
}): FirstRunSetupService {
|
||||
const setupStatePath = getSetupStatePath(deps.configDir);
|
||||
@@ -262,6 +297,8 @@ export function createFirstRunSetupService(deps: {
|
||||
const detectedWindowsMpvShortcuts = isWindows
|
||||
? await deps.detectWindowsMpvShortcuts?.()
|
||||
: undefined;
|
||||
const commandLineLauncher =
|
||||
(await deps.detectCommandLineLauncher?.()) ?? createUnsupportedCommandLineLauncherSnapshot();
|
||||
const installedWindowsMpvShortcuts = {
|
||||
startMenuInstalled: detectedWindowsMpvShortcuts?.startMenuInstalled ?? false,
|
||||
desktopInstalled: detectedWindowsMpvShortcuts?.desktopInstalled ?? false,
|
||||
@@ -291,6 +328,7 @@ export function createFirstRunSetupService(deps: {
|
||||
status: getWindowsMpvShortcutStatus(state, installedWindowsMpvShortcuts),
|
||||
message: null,
|
||||
},
|
||||
commandLineLauncher,
|
||||
message,
|
||||
state,
|
||||
} satisfies SetupStatusSnapshot;
|
||||
@@ -453,6 +491,36 @@ export function createFirstRunSetupService(deps: {
|
||||
result.message,
|
||||
);
|
||||
},
|
||||
installBun: async () => {
|
||||
if (!deps.installBun) {
|
||||
return refreshWithState(readState(), 'Bun installation is unavailable in this runtime.');
|
||||
}
|
||||
const result = await deps.installBun();
|
||||
return refreshWithState(
|
||||
writeState({
|
||||
...readState(),
|
||||
bunInstallStatus: result.ok ? 'installed' : 'failed',
|
||||
}),
|
||||
result.message,
|
||||
);
|
||||
},
|
||||
installCommandLineLauncher: async () => {
|
||||
if (!deps.installCommandLineLauncher) {
|
||||
return refreshWithState(
|
||||
readState(),
|
||||
'Command-line launcher installation is unavailable in this runtime.',
|
||||
);
|
||||
}
|
||||
const result = await deps.installCommandLineLauncher();
|
||||
return refreshWithState(
|
||||
writeState({
|
||||
...readState(),
|
||||
launcherInstallStatus: result.ok ? 'installed' : 'failed',
|
||||
launcherInstallPath: result.ok ? result.installPath : null,
|
||||
}),
|
||||
result.message,
|
||||
);
|
||||
},
|
||||
isSetupCompleted: () => completed || isSetupCompleted(readState()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,32 @@ import {
|
||||
createOpenFirstRunSetupWindowHandler,
|
||||
parseFirstRunSetupSubmissionUrl,
|
||||
} from './first-run-setup-window';
|
||||
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
|
||||
|
||||
function createCommandLineLauncherSnapshot(
|
||||
overrides: Partial<CommandLineLauncherSnapshot> = {},
|
||||
): CommandLineLauncherSnapshot {
|
||||
return {
|
||||
supported: true,
|
||||
bun: {
|
||||
status: 'missing',
|
||||
commandPath: null,
|
||||
version: null,
|
||||
installMethod: 'official-script',
|
||||
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
|
||||
message: null,
|
||||
},
|
||||
launcher: {
|
||||
status: 'not_installed',
|
||||
commandPath: null,
|
||||
installPath: '/home/tester/.local/bin/subminer',
|
||||
pathDir: '/home/tester/.local/bin',
|
||||
shadowedBy: null,
|
||||
message: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish state', () => {
|
||||
const html = buildFirstRunSetupHtml({
|
||||
@@ -26,6 +52,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||
message: 'Waiting for dictionaries',
|
||||
});
|
||||
|
||||
@@ -58,6 +85,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
||||
desktopInstalled: false,
|
||||
status: 'installed',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||
message: null,
|
||||
});
|
||||
|
||||
@@ -88,6 +116,7 @@ test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirm
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||
message: null,
|
||||
});
|
||||
|
||||
@@ -124,6 +153,7 @@ test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', (
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||
message: null,
|
||||
});
|
||||
|
||||
@@ -149,6 +179,7 @@ test('buildFirstRunSetupHtml explains the config blocker when setup is missing c
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||
message: null,
|
||||
});
|
||||
|
||||
@@ -173,6 +204,7 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||
message: null,
|
||||
});
|
||||
|
||||
@@ -196,6 +228,20 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
||||
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
|
||||
action: 'refresh',
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=install-bun'),
|
||||
{
|
||||
action: 'install-bun',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl(
|
||||
'subminer://first-run-setup?action=install-command-line-launcher',
|
||||
),
|
||||
{
|
||||
action: 'install-command-line-launcher',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=remove-legacy-plugin'),
|
||||
{
|
||||
@@ -209,6 +255,59 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
||||
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
|
||||
});
|
||||
|
||||
test('buildFirstRunSetupHtml renders command-line launcher section and actions', () => {
|
||||
const html = buildFirstRunSetupHtml({
|
||||
configReady: true,
|
||||
dictionaryCount: 1,
|
||||
canFinish: true,
|
||||
externalYomitanConfigured: false,
|
||||
pluginStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
mpvExecutablePath: '',
|
||||
mpvExecutablePathStatus: 'blank',
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot({
|
||||
bun: {
|
||||
status: 'failed',
|
||||
commandPath: null,
|
||||
version: null,
|
||||
installMethod: 'official-script',
|
||||
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
|
||||
message: 'network failed',
|
||||
},
|
||||
launcher: {
|
||||
status: 'installed_bun_missing',
|
||||
commandPath: '/home/tester/.local/bin/subminer',
|
||||
installPath: '/home/tester/.local/bin/subminer',
|
||||
pathDir: '/home/tester/.local/bin',
|
||||
shadowedBy: null,
|
||||
message: 'Bun is missing.',
|
||||
},
|
||||
}),
|
||||
message: null,
|
||||
});
|
||||
|
||||
assert.match(html, /Command line launcher/);
|
||||
assert.match(html, /Optional\. Setup can finish without Bun or the launcher\./);
|
||||
assert.match(html, /Bun runtime/);
|
||||
assert.match(html, /Failed/);
|
||||
assert.match(html, /bash -lc curl -fsSL https:\/\/bun\.com\/install \| bash/);
|
||||
assert.match(html, /Install Bun/);
|
||||
assert.match(html, /action=install-bun/);
|
||||
assert.match(html, /SubMiner launcher/);
|
||||
assert.match(html, /Installed, Bun missing/);
|
||||
assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/);
|
||||
assert.match(html, /action=install-command-line-launcher/);
|
||||
assert.match(html, /<button class="primary" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/);
|
||||
});
|
||||
|
||||
test('first-run setup window handler focuses existing window', () => {
|
||||
const calls: string[] = [];
|
||||
const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({
|
||||
@@ -304,6 +403,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||
message: null,
|
||||
}),
|
||||
buildSetupHtml: () => '<html></html>',
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
|
||||
import type {
|
||||
BunSnapshot,
|
||||
CommandLineLauncherSnapshot,
|
||||
LauncherSnapshot,
|
||||
} from './command-line-launcher';
|
||||
|
||||
type FocusableWindowLike = {
|
||||
focus: () => void;
|
||||
@@ -20,6 +25,8 @@ export type FirstRunSetupAction =
|
||||
| 'configure-mpv-executable-path'
|
||||
| 'remove-legacy-plugin'
|
||||
| 'configure-windows-mpv-shortcuts'
|
||||
| 'install-bun'
|
||||
| 'install-command-line-launcher'
|
||||
| 'open-yomitan-settings'
|
||||
| 'refresh'
|
||||
| 'finish';
|
||||
@@ -49,6 +56,7 @@ export interface FirstRunSetupHtmlModel {
|
||||
desktopInstalled: boolean;
|
||||
status: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||
};
|
||||
commandLineLauncher: CommandLineLauncherSnapshot;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
@@ -64,6 +72,125 @@ function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'da
|
||||
return `<span class="badge ${tone}">${escapeHtml(value)}</span>`;
|
||||
}
|
||||
|
||||
function formatCommand(command: string[] | null): string {
|
||||
return command?.join(' ') ?? 'No install command detected';
|
||||
}
|
||||
|
||||
function getBunStatusLabel(status: BunSnapshot['status']): string {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'Ready';
|
||||
case 'installing':
|
||||
return 'Installing';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
case 'missing':
|
||||
return 'Missing';
|
||||
}
|
||||
}
|
||||
|
||||
function getLauncherStatusLabel(status: LauncherSnapshot['status']): string {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return 'Ready';
|
||||
case 'installed_bun_missing':
|
||||
return 'Installed, Bun missing';
|
||||
case 'not_installed':
|
||||
return 'Not installed';
|
||||
case 'not_on_path':
|
||||
return 'Not on PATH';
|
||||
case 'shadowed':
|
||||
return 'Shadowed';
|
||||
case 'not_installable':
|
||||
return 'Not installable';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
}
|
||||
}
|
||||
|
||||
function getToolTone(status: BunSnapshot['status']): 'ready' | 'warn' | 'muted' | 'danger' {
|
||||
if (status === 'ready') return 'ready';
|
||||
if (status === 'failed') return 'danger';
|
||||
if (status === 'installing') return 'muted';
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
function getLauncherTone(
|
||||
status: LauncherSnapshot['status'],
|
||||
): 'ready' | 'warn' | 'muted' | 'danger' {
|
||||
if (status === 'ready') return 'ready';
|
||||
if (status === 'failed') return 'danger';
|
||||
if (status === 'installed_bun_missing' || status === 'not_installed') return 'warn';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLauncherSnapshot): string {
|
||||
if (!commandLineLauncher.supported) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const bun = commandLineLauncher.bun;
|
||||
const launcher = commandLineLauncher.launcher;
|
||||
const bunMeta =
|
||||
bun.status === 'ready'
|
||||
? [
|
||||
bun.commandPath ? `Path: ${bun.commandPath}` : null,
|
||||
bun.version ? `Version: ${bun.version}` : null,
|
||||
].filter(Boolean)
|
||||
: [
|
||||
bun.installMethod ? `Method: ${bun.installMethod}` : null,
|
||||
`Command: ${formatCommand(bun.installCommand)}`,
|
||||
bun.message,
|
||||
].filter(Boolean);
|
||||
const launcherMeta = [
|
||||
launcher.commandPath ? `Command: ${launcher.commandPath}` : null,
|
||||
launcher.installPath ? `Install target: ${launcher.installPath}` : null,
|
||||
launcher.pathDir ? `PATH dir: ${launcher.pathDir}` : null,
|
||||
launcher.shadowedBy ? `Shadowed by: ${launcher.shadowedBy}` : null,
|
||||
launcher.message,
|
||||
bun.status !== 'ready' ? 'Warning: subminer will not run until Bun is available.' : null,
|
||||
].filter(Boolean);
|
||||
const bunInstallButton =
|
||||
bun.status === 'missing' || bun.status === 'failed'
|
||||
? `<button onclick="window.location.href='subminer://first-run-setup?action=install-bun'">Install Bun</button>`
|
||||
: '';
|
||||
const launcherButtonDisabled = launcher.status === 'failed' ? '' : '';
|
||||
|
||||
return `
|
||||
<section class="setup-section">
|
||||
<div class="section-head">
|
||||
<h2>Command line launcher</h2>
|
||||
<div class="meta">Optional. Setup can finish without Bun or the launcher.</div>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<strong>Bun runtime</strong>
|
||||
${bunMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
|
||||
</div>
|
||||
${renderStatusBadge(getBunStatusLabel(bun.status), getToolTone(bun.status))}
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
${bunInstallButton}
|
||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card block">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<strong>SubMiner launcher</strong>
|
||||
${launcherMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
|
||||
</div>
|
||||
${renderStatusBadge(getLauncherStatusLabel(launcher.status), getLauncherTone(launcher.status))}
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<button ${launcherButtonDisabled} onclick="window.location.href='subminer://first-run-setup?action=install-command-line-launcher'">Install launcher</button>
|
||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
const legacyMpvPluginPaths = model.legacyMpvPluginPaths ?? [];
|
||||
const finishButtonLabel =
|
||||
@@ -264,6 +391,16 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.setup-section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.section-head {
|
||||
margin: 14px 0 8px;
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
label {
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
@@ -307,6 +444,12 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
@@ -386,6 +529,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
</div>
|
||||
${mpvExecutablePathCard}
|
||||
${windowsShortcutCard}
|
||||
${renderCommandLineLauncherSection(model.commandLineLauncher)}
|
||||
${legacyPluginCard}
|
||||
<div class="actions">
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
||||
@@ -409,6 +553,8 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
||||
action !== 'configure-mpv-executable-path' &&
|
||||
action !== 'remove-legacy-plugin' &&
|
||||
action !== 'configure-windows-mpv-shortcuts' &&
|
||||
action !== 'install-bun' &&
|
||||
action !== 'install-command-line-launcher' &&
|
||||
action !== 'open-yomitan-settings' &&
|
||||
action !== 'refresh' &&
|
||||
action !== 'finish'
|
||||
|
||||
@@ -11,6 +11,9 @@ export function createBuildReloadConfigMainDepsHandler(deps: ReloadConfigMainDep
|
||||
showDesktopNotification: (title: string, options: { body: string }) =>
|
||||
deps.showDesktopNotification(title, options),
|
||||
startConfigHotReload: () => deps.startConfigHotReload(),
|
||||
shouldRefreshAnilistClientSecretState: deps.shouldRefreshAnilistClientSecretState
|
||||
? () => deps.shouldRefreshAnilistClientSecretState?.() !== false
|
||||
: undefined,
|
||||
refreshAnilistClientSecretState: (options: { force: boolean }) =>
|
||||
deps.refreshAnilistClientSecretState(options),
|
||||
failHandlers: {
|
||||
|
||||
@@ -93,6 +93,36 @@ test('createReloadConfigHandler fails startup for parse errors', () => {
|
||||
assert.equal(calls.includes('hotReload:start'), false);
|
||||
});
|
||||
|
||||
test('createReloadConfigHandler can skip AniList refresh for headless commands', async () => {
|
||||
const calls: string[] = [];
|
||||
const reloadConfig = createReloadConfigHandler({
|
||||
reloadConfigStrict: () => ({
|
||||
ok: true,
|
||||
path: '/tmp/config.jsonc',
|
||||
warnings: [],
|
||||
}),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarning: (message) => calls.push(`warn:${message}`),
|
||||
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
|
||||
startConfigHotReload: () => calls.push('hotReload:start'),
|
||||
shouldRefreshAnilistClientSecretState: () => false,
|
||||
refreshAnilistClientSecretState: async () => {
|
||||
calls.push('refresh');
|
||||
},
|
||||
failHandlers: {
|
||||
logError: (details) => calls.push(`error:${details}`),
|
||||
showErrorBox: (title, details) => calls.push(`dialog:${title}:${details}`),
|
||||
quit: () => calls.push('quit'),
|
||||
},
|
||||
});
|
||||
|
||||
reloadConfig();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(calls.includes('refresh'), false);
|
||||
assert.ok(calls.includes('hotReload:start'));
|
||||
});
|
||||
|
||||
test('createCriticalConfigErrorHandler formats and fails', () => {
|
||||
const calls: string[] = [];
|
||||
const exitCodes: number[] = [];
|
||||
|
||||
@@ -31,6 +31,7 @@ export type ReloadConfigRuntimeDeps = {
|
||||
force: boolean;
|
||||
allowSetupPrompt?: boolean;
|
||||
}) => Promise<unknown>;
|
||||
shouldRefreshAnilistClientSecretState?: () => boolean;
|
||||
failHandlers: {
|
||||
logError: (details: string) => void;
|
||||
showErrorBox: (title: string, details: string) => void;
|
||||
@@ -75,7 +76,9 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () =>
|
||||
}
|
||||
|
||||
deps.startConfigHotReload();
|
||||
void deps.refreshAnilistClientSecretState({ force: true, allowSetupPrompt: false });
|
||||
if (deps.shouldRefreshAnilistClientSecretState?.() !== false) {
|
||||
void deps.refreshAnilistClientSecretState({ force: true, allowSetupPrompt: false });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { shouldEnsureTrayOnStartupForInitialArgs } from './startup-tray-policy';
|
||||
import {
|
||||
shouldEnsureTrayOnStartupForInitialArgs,
|
||||
shouldQuitOnWindowAllClosedForTrayState,
|
||||
} from './startup-tray-policy';
|
||||
|
||||
test('startup tray policy enables tray on Windows by default', () => {
|
||||
assert.equal(shouldEnsureTrayOnStartupForInitialArgs('win32', null), true);
|
||||
@@ -18,3 +21,24 @@ test('startup tray policy skips tray for direct youtube playback on Windows', ()
|
||||
test('startup tray policy skips tray outside Windows', () => {
|
||||
assert.equal(shouldEnsureTrayOnStartupForInitialArgs('linux', null), false);
|
||||
});
|
||||
|
||||
test('window-all-closed keeps tray-resident app alive', () => {
|
||||
assert.equal(
|
||||
shouldQuitOnWindowAllClosedForTrayState({ backgroundMode: false, hasTray: true }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('window-all-closed quits non-background app without tray', () => {
|
||||
assert.equal(
|
||||
shouldQuitOnWindowAllClosedForTrayState({ backgroundMode: false, hasTray: false }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('window-all-closed keeps background app alive without tray', () => {
|
||||
assert.equal(
|
||||
shouldQuitOnWindowAllClosedForTrayState({ backgroundMode: true, hasTray: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,3 +12,12 @@ export function shouldEnsureTrayOnStartupForInitialArgs(
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldQuitOnWindowAllClosedForTrayState(options: {
|
||||
backgroundMode: boolean;
|
||||
hasTray: boolean;
|
||||
}): boolean {
|
||||
if (options.backgroundMode) return false;
|
||||
if (options.hasTray) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
buildTrayMenuTemplateRuntime: (handlers) => {
|
||||
handlers.openSessionHelp();
|
||||
handlers.openTexthookerInBrowser();
|
||||
calls.push(`show-texthooker:${handlers.showTexthookerPage}`);
|
||||
handlers.openFirstRunSetup();
|
||||
handlers.openWindowsMpvLauncherSetup();
|
||||
handlers.openYomitanSettings();
|
||||
@@ -50,6 +51,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
handlers.openJellyfinSetup();
|
||||
handlers.toggleJellyfinDiscovery();
|
||||
handlers.openAnilistSetup();
|
||||
handlers.checkForUpdates();
|
||||
handlers.quitApp();
|
||||
return [{ label: 'ok' }] as never;
|
||||
},
|
||||
@@ -60,6 +62,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
isOverlayRuntimeInitialized: () => initialized,
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
@@ -72,6 +75,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
calls.push('jellyfin-discovery');
|
||||
},
|
||||
openAnilistSetupWindow: () => calls.push('anilist'),
|
||||
checkForUpdates: () => calls.push('updates'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
@@ -81,6 +85,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'init',
|
||||
'help',
|
||||
'texthooker',
|
||||
'show-texthooker:true',
|
||||
'setup',
|
||||
'setup',
|
||||
'yomitan',
|
||||
@@ -88,6 +93,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'jellyfin',
|
||||
'jellyfin-discovery',
|
||||
'anilist',
|
||||
'updates',
|
||||
'quit',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openSessionHelp: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: boolean;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
@@ -41,12 +42,14 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
jellyfinDiscoveryActive: boolean;
|
||||
toggleJellyfinDiscovery: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
checkForUpdates: () => void;
|
||||
quitApp: () => void;
|
||||
}) => TMenuItem[];
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
openSessionHelpModal: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: () => boolean;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
@@ -57,6 +60,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
isJellyfinDiscoveryActive: () => boolean;
|
||||
toggleJellyfinDiscovery: () => void | Promise<void>;
|
||||
openAnilistSetupWindow: () => void;
|
||||
checkForUpdates: () => void;
|
||||
quitApp: () => void;
|
||||
}) {
|
||||
return (): TMenuItem[] => {
|
||||
@@ -70,6 +74,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openTexthookerInBrowser: () => {
|
||||
deps.openTexthookerInBrowser();
|
||||
},
|
||||
showTexthookerPage: deps.showTexthookerPage(),
|
||||
openFirstRunSetup: () => {
|
||||
deps.openFirstRunSetupWindow();
|
||||
},
|
||||
@@ -98,6 +103,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openAnilistSetup: () => {
|
||||
deps.openAnilistSetupWindow();
|
||||
},
|
||||
checkForUpdates: () => {
|
||||
deps.checkForUpdates();
|
||||
},
|
||||
quitApp: () => {
|
||||
deps.quitApp();
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
openSessionHelpModal: () => calls.push('help'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
@@ -38,12 +39,14 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
calls.push('jellyfin-discovery');
|
||||
},
|
||||
openAnilistSetupWindow: () => calls.push('anilist'),
|
||||
checkForUpdates: () => calls.push('updates'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
})();
|
||||
|
||||
const template = menuDeps.buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => calls.push('open-help'),
|
||||
openTexthookerInBrowser: () => calls.push('open-texthooker'),
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => calls.push('open-setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
|
||||
@@ -55,6 +58,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'),
|
||||
openAnilistSetup: () => calls.push('open-anilist'),
|
||||
checkForUpdates: () => calls.push('open-updates'),
|
||||
quitApp: () => calls.push('quit-app'),
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openSessionHelp: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: boolean;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
@@ -40,12 +41,14 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
jellyfinDiscoveryActive: boolean;
|
||||
toggleJellyfinDiscovery: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
checkForUpdates: () => void;
|
||||
quitApp: () => void;
|
||||
}) => TMenuItem[];
|
||||
initializeOverlayRuntime: () => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
openSessionHelpModal: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: () => boolean;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
@@ -56,6 +59,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
isJellyfinDiscoveryActive: () => boolean;
|
||||
toggleJellyfinDiscovery: () => void | Promise<void>;
|
||||
openAnilistSetupWindow: () => void;
|
||||
checkForUpdates: () => void;
|
||||
quitApp: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
@@ -64,6 +68,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
|
||||
openSessionHelpModal: deps.openSessionHelpModal,
|
||||
openTexthookerInBrowser: deps.openTexthookerInBrowser,
|
||||
showTexthookerPage: deps.showTexthookerPage,
|
||||
showFirstRunSetup: deps.showFirstRunSetup,
|
||||
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||
@@ -74,6 +79,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive,
|
||||
toggleJellyfinDiscovery: deps.toggleJellyfinDiscovery,
|
||||
openAnilistSetupWindow: deps.openAnilistSetupWindow,
|
||||
checkForUpdates: deps.checkForUpdates,
|
||||
quitApp: deps.quitApp,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
isOverlayRuntimeInitialized: () => overlayInitialized,
|
||||
openSessionHelpModal: () => {},
|
||||
openTexthookerInBrowser: () => {},
|
||||
showTexthookerPage: () => true,
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => {},
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
@@ -36,6 +37,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
isJellyfinDiscoveryActive: () => false,
|
||||
toggleJellyfinDiscovery: () => {},
|
||||
openAnilistSetupWindow: () => {},
|
||||
checkForUpdates: () => {},
|
||||
quitApp: () => {},
|
||||
},
|
||||
ensureTrayDeps: {
|
||||
|
||||
@@ -31,6 +31,7 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => calls.push('help'),
|
||||
openTexthookerInBrowser: () => calls.push('texthooker'),
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
|
||||
@@ -42,10 +43,11 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'),
|
||||
openAnilistSetup: () => calls.push('anilist'),
|
||||
checkForUpdates: () => calls.push('updates'),
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
assert.equal(template.length, 11);
|
||||
assert.equal(template.length, 12);
|
||||
assert.equal(
|
||||
template.some((entry) => entry.label === 'Open Overlay'),
|
||||
false,
|
||||
@@ -58,15 +60,25 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
template[0]!.click?.();
|
||||
assert.equal(template[1]!.label, 'Open Texthooker');
|
||||
template[1]!.click?.();
|
||||
template[9]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[10]!.click?.();
|
||||
assert.deepEqual(calls, ['jellyfin-discovery', 'help', 'texthooker', 'separator', 'quit']);
|
||||
assert.equal(template[9]!.label, 'Check for Updates');
|
||||
template[9]!.click?.();
|
||||
template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[11]!.click?.();
|
||||
assert.deepEqual(calls, [
|
||||
'jellyfin-discovery',
|
||||
'help',
|
||||
'texthooker',
|
||||
'updates',
|
||||
'separator',
|
||||
'quit',
|
||||
]);
|
||||
});
|
||||
|
||||
test('tray menu template omits first-run setup entry when setup is complete', () => {
|
||||
const labels = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => undefined,
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
@@ -78,6 +90,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: () => undefined,
|
||||
openAnilistSetup: () => undefined,
|
||||
checkForUpdates: () => undefined,
|
||||
quitApp: () => undefined,
|
||||
})
|
||||
.map((entry) => entry.label)
|
||||
@@ -88,10 +101,36 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
assert.equal(labels.includes('Jellyfin Discovery'), false);
|
||||
});
|
||||
|
||||
test('tray menu template omits texthooker entry when texthooker page is disabled', () => {
|
||||
const labels = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
showTexthookerPage: false,
|
||||
openFirstRunSetup: () => undefined,
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
showJellyfinDiscovery: false,
|
||||
jellyfinDiscoveryActive: false,
|
||||
toggleJellyfinDiscovery: () => undefined,
|
||||
openAnilistSetup: () => undefined,
|
||||
checkForUpdates: () => undefined,
|
||||
quitApp: () => undefined,
|
||||
})
|
||||
.map((entry) => entry.label)
|
||||
.filter(Boolean);
|
||||
|
||||
assert.equal(labels.includes('Open Texthooker'), false);
|
||||
});
|
||||
|
||||
test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
const template = buildTrayMenuTemplateRuntime({
|
||||
openSessionHelp: () => undefined,
|
||||
openTexthookerInBrowser: () => undefined,
|
||||
showTexthookerPage: true,
|
||||
openFirstRunSetup: () => undefined,
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
@@ -103,6 +142,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
|
||||
jellyfinDiscoveryActive: true,
|
||||
toggleJellyfinDiscovery: () => undefined,
|
||||
openAnilistSetup: () => undefined,
|
||||
checkForUpdates: () => undefined,
|
||||
quitApp: () => undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export function resolveTrayIconPathRuntime(deps: {
|
||||
export type TrayMenuActionHandlers = {
|
||||
openSessionHelp: () => void;
|
||||
openTexthookerInBrowser: () => void;
|
||||
showTexthookerPage: boolean;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
@@ -43,6 +44,7 @@ export type TrayMenuActionHandlers = {
|
||||
jellyfinDiscoveryActive: boolean;
|
||||
toggleJellyfinDiscovery: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
checkForUpdates: () => void;
|
||||
quitApp: () => void;
|
||||
};
|
||||
|
||||
@@ -58,10 +60,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
label: 'Open Help',
|
||||
click: handlers.openSessionHelp,
|
||||
},
|
||||
{
|
||||
label: 'Open Texthooker',
|
||||
click: handlers.openTexthookerInBrowser,
|
||||
},
|
||||
...(handlers.showTexthookerPage
|
||||
? [
|
||||
{
|
||||
label: 'Open Texthooker',
|
||||
click: handlers.openTexthookerInBrowser,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(handlers.showFirstRunSetup
|
||||
? [
|
||||
{
|
||||
@@ -105,6 +111,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
label: 'Configure AniList',
|
||||
click: handlers.openAnilistSetup,
|
||||
},
|
||||
{
|
||||
label: 'Check for Updates',
|
||||
click: handlers.checkForUpdates,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { configureAutoUpdater, type ElectronAutoUpdaterLike } from './app-updater';
|
||||
|
||||
type UpdaterLogger = {
|
||||
info: (message: string) => void;
|
||||
debug: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
};
|
||||
|
||||
test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => {
|
||||
const logged: string[] = [];
|
||||
const updater: ElectronAutoUpdaterLike & { logger?: UpdaterLogger | null } = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: true,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => null,
|
||||
downloadUpdate: async () => [],
|
||||
quitAndInstall: () => {},
|
||||
};
|
||||
|
||||
configureAutoUpdater(updater, (message) => logged.push(message));
|
||||
|
||||
assert.equal(updater.autoDownload, false);
|
||||
assert.equal(updater.allowPrerelease, false);
|
||||
assert.equal(updater.allowDowngrade, false);
|
||||
assert.ok(updater.logger);
|
||||
|
||||
updater.logger.info('Checking for update');
|
||||
updater.logger.debug('Generated new staging user ID');
|
||||
updater.logger.warn('metadata missing');
|
||||
updater.logger.error('download failed');
|
||||
|
||||
assert.deepEqual(logged, ['metadata missing', 'download failed']);
|
||||
});
|
||||
|
||||
test('configureAutoUpdater allows prereleases only for the prerelease channel', () => {
|
||||
const updater: ElectronAutoUpdaterLike = {
|
||||
autoDownload: true,
|
||||
allowPrerelease: false,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
checkForUpdates: async () => null,
|
||||
downloadUpdate: async () => [],
|
||||
quitAndInstall: () => {},
|
||||
};
|
||||
|
||||
configureAutoUpdater(updater, () => {}, 'prerelease');
|
||||
assert.equal(updater.allowPrerelease, true);
|
||||
|
||||
configureAutoUpdater(updater, () => {}, 'stable');
|
||||
assert.equal(updater.allowPrerelease, false);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { autoUpdater as electronAutoUpdater } from 'electron-updater';
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
import { compareSemverLike } from './release-assets';
|
||||
|
||||
export interface AppUpdateCheckResult {
|
||||
available: boolean;
|
||||
version: string;
|
||||
canUpdate: boolean;
|
||||
}
|
||||
|
||||
export interface ElectronUpdaterLoggerLike {
|
||||
info?: (message: string, ...args: unknown[]) => void;
|
||||
debug?: (message: string, ...args: unknown[]) => void;
|
||||
warn?: (message: string, ...args: unknown[]) => void;
|
||||
error?: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export interface ElectronAutoUpdaterLike {
|
||||
autoDownload: boolean;
|
||||
allowPrerelease: boolean;
|
||||
allowDowngrade: boolean;
|
||||
logger?: ElectronUpdaterLoggerLike | null;
|
||||
checkForUpdates: () => Promise<{
|
||||
updateInfo?: {
|
||||
version?: string;
|
||||
};
|
||||
} | null>;
|
||||
downloadUpdate: () => Promise<unknown>;
|
||||
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
|
||||
}
|
||||
|
||||
export function configureAutoUpdater(
|
||||
updater: ElectronAutoUpdaterLike,
|
||||
log: (message: string) => void = () => {},
|
||||
channel: UpdateChannel = 'stable',
|
||||
): ElectronAutoUpdaterLike {
|
||||
updater.autoDownload = false;
|
||||
updater.allowPrerelease = channel === 'prerelease';
|
||||
updater.allowDowngrade = false;
|
||||
updater.logger = {
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
warn: (message) => log(message),
|
||||
error: (message) => log(message),
|
||||
};
|
||||
return updater;
|
||||
}
|
||||
|
||||
export function createElectronAppUpdater(options: {
|
||||
currentVersion: string;
|
||||
isPackaged: boolean;
|
||||
updater?: ElectronAutoUpdaterLike;
|
||||
log: (message: string) => void;
|
||||
getChannel?: () => UpdateChannel;
|
||||
}) {
|
||||
const getChannel = options.getChannel ?? (() => 'stable' as const);
|
||||
const updater = configureAutoUpdater(
|
||||
options.updater ?? electronAutoUpdater,
|
||||
options.log,
|
||||
getChannel(),
|
||||
);
|
||||
|
||||
return {
|
||||
async checkForUpdates(channel?: UpdateChannel): Promise<AppUpdateCheckResult> {
|
||||
if (!options.isPackaged) {
|
||||
return {
|
||||
available: false,
|
||||
version: options.currentVersion,
|
||||
canUpdate: false,
|
||||
};
|
||||
}
|
||||
configureAutoUpdater(updater, options.log, channel ?? getChannel());
|
||||
const result = await updater.checkForUpdates();
|
||||
const version = result?.updateInfo?.version ?? options.currentVersion;
|
||||
return {
|
||||
available: compareSemverLike(version, options.currentVersion) > 0,
|
||||
version,
|
||||
canUpdate: true,
|
||||
};
|
||||
},
|
||||
async downloadUpdate(): Promise<void> {
|
||||
if (!options.isPackaged) {
|
||||
options.log('Skipping app update download because this build is not packaged.');
|
||||
return;
|
||||
}
|
||||
await updater.downloadUpdate();
|
||||
},
|
||||
quitAndInstall(): void {
|
||||
updater.quitAndInstall(false, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
buildProtectedLauncherUpdateCommand,
|
||||
looksLikeSubminerLauncher,
|
||||
updateLauncherAtPath,
|
||||
} from './launcher-updater';
|
||||
|
||||
const launcherBytes = Buffer.from('#!/usr/bin/env bash\n# SubMiner launcher\nexec SubMiner "$@"\n');
|
||||
const launcherHash = createHash('sha256').update(launcherBytes).digest('hex');
|
||||
|
||||
test('looksLikeSubminerLauncher rejects unrelated executable content', () => {
|
||||
assert.equal(looksLikeSubminerLauncher(Buffer.from('#!/bin/sh\necho nope\n')), false);
|
||||
assert.equal(looksLikeSubminerLauncher(Buffer.from('SubMiner launcher binary payload')), true);
|
||||
});
|
||||
|
||||
test('buildProtectedLauncherUpdateCommand uses sudo curl and chmod for protected paths', () => {
|
||||
assert.equal(
|
||||
buildProtectedLauncherUpdateCommand(
|
||||
'https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer',
|
||||
'/usr/local/bin/subminer',
|
||||
),
|
||||
'sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer && sudo chmod +x /usr/local/bin/subminer',
|
||||
);
|
||||
});
|
||||
|
||||
test('updateLauncherAtPath verifies hash and atomically replaces writable launcher', async () => {
|
||||
const writes: Array<{ path: string; data: Buffer }> = [];
|
||||
const renames: Array<{ from: string; to: string }> = [];
|
||||
const chmods: Array<{ path: string; mode: number }> = [];
|
||||
|
||||
const result = await updateLauncherAtPath({
|
||||
launcherPath: '/home/kyle/.local/bin/subminer',
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: launcherHash,
|
||||
download: async () => launcherBytes,
|
||||
fs: {
|
||||
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
|
||||
stat: async () => ({ isFile: () => true, mode: 0o755 }),
|
||||
access: async () => undefined,
|
||||
writeFile: async (filePath, data) => {
|
||||
writes.push({ path: filePath, data: Buffer.from(data) });
|
||||
},
|
||||
chmod: async (filePath, mode) => {
|
||||
chmods.push({ path: filePath, mode });
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
renames.push({ from, to });
|
||||
},
|
||||
unlink: async () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0]!.path, '/home/kyle/.local/bin/.subminer.update');
|
||||
assert.equal(writes[0]!.data.equals(launcherBytes), true);
|
||||
assert.deepEqual(chmods, [{ path: '/home/kyle/.local/bin/.subminer.update', mode: 0o755 }]);
|
||||
assert.deepEqual(renames, [
|
||||
{ from: '/home/kyle/.local/bin/.subminer.update', to: '/home/kyle/.local/bin/subminer' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('updateLauncherAtPath reports protected command without replacing non-writable launcher', async () => {
|
||||
const result = await updateLauncherAtPath({
|
||||
launcherPath: '/usr/local/bin/subminer',
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: launcherHash,
|
||||
download: async () => launcherBytes,
|
||||
fs: {
|
||||
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
|
||||
stat: async () => ({ isFile: () => true, mode: 0o755 }),
|
||||
access: async () => {
|
||||
throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
||||
},
|
||||
writeFile: async () => {
|
||||
throw new Error('unexpected write');
|
||||
},
|
||||
chmod: async () => undefined,
|
||||
rename: async () => undefined,
|
||||
unlink: async () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'protected');
|
||||
assert.match(result.command ?? '', /^sudo curl -fSL https:\/\/example\.test\/subminer/);
|
||||
});
|
||||
|
||||
test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher content', async () => {
|
||||
const suspicious = await updateLauncherAtPath({
|
||||
launcherPath: '/home/kyle/bin/subminer',
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: launcherHash,
|
||||
download: async () => launcherBytes,
|
||||
fs: {
|
||||
readFile: async () => Buffer.from('#!/bin/sh\necho not-subminer\n'),
|
||||
stat: async () => ({ isFile: () => true, mode: 0o755 }),
|
||||
access: async () => undefined,
|
||||
writeFile: async () => undefined,
|
||||
chmod: async () => undefined,
|
||||
rename: async () => undefined,
|
||||
unlink: async () => undefined,
|
||||
},
|
||||
});
|
||||
const mismatch = await updateLauncherAtPath({
|
||||
launcherPath: '/home/kyle/.local/bin/subminer',
|
||||
assetUrl: 'https://example.test/subminer',
|
||||
expectedSha256: '0'.repeat(64),
|
||||
download: async () => launcherBytes,
|
||||
fs: {
|
||||
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
|
||||
stat: async () => ({ isFile: () => true, mode: 0o755 }),
|
||||
access: async () => undefined,
|
||||
writeFile: async () => {
|
||||
throw new Error('unexpected write');
|
||||
},
|
||||
chmod: async () => undefined,
|
||||
rename: async () => undefined,
|
||||
unlink: async () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(suspicious.status, 'skipped');
|
||||
assert.equal(mismatch.status, 'hash-mismatch');
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { GitHubRelease } from './release-assets';
|
||||
import { findReleaseAsset } from './release-assets';
|
||||
|
||||
type StatLike = {
|
||||
isFile: () => boolean;
|
||||
mode?: number;
|
||||
};
|
||||
|
||||
export type LauncherUpdateStatus =
|
||||
| 'updated'
|
||||
| 'skipped'
|
||||
| 'protected'
|
||||
| 'hash-mismatch'
|
||||
| 'not-found'
|
||||
| 'missing-asset';
|
||||
|
||||
export interface LauncherUpdateResult {
|
||||
status: LauncherUpdateStatus;
|
||||
path?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface LauncherUpdateFileSystem {
|
||||
readFile: (targetPath: string) => Promise<Buffer | string>;
|
||||
stat: (targetPath: string) => Promise<StatLike>;
|
||||
access: (targetPath: string) => Promise<void>;
|
||||
writeFile: (targetPath: string, data: Buffer) => Promise<void>;
|
||||
chmod: (targetPath: string, mode: number) => Promise<void>;
|
||||
rename: (fromPath: string, toPath: string) => Promise<void>;
|
||||
unlink: (targetPath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function looksLikeSubminerLauncher(content: Buffer | string): boolean {
|
||||
const text = Buffer.isBuffer(content) ? content.toString('utf8') : content;
|
||||
return (
|
||||
text.includes('SubMiner launcher') ||
|
||||
text.includes('Launch MPV with SubMiner') ||
|
||||
text.includes('SUBMINER_APPIMAGE_PATH') ||
|
||||
text.includes('SubMiner.app') ||
|
||||
text.includes('SubMiner.AppImage')
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProtectedLauncherUpdateCommand(
|
||||
assetUrl: string,
|
||||
launcherPath: string,
|
||||
): string {
|
||||
return `sudo curl -fSL ${assetUrl} -o ${launcherPath} && sudo chmod +x ${launcherPath}`;
|
||||
}
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function defaultFs(): LauncherUpdateFileSystem {
|
||||
return {
|
||||
readFile: (targetPath) => fs.promises.readFile(targetPath),
|
||||
stat: (targetPath) => fs.promises.stat(targetPath),
|
||||
access: async (targetPath) => {
|
||||
await fs.promises.access(targetPath, fs.constants.W_OK);
|
||||
},
|
||||
writeFile: (targetPath, data) => fs.promises.writeFile(targetPath, data),
|
||||
chmod: (targetPath, mode) => fs.promises.chmod(targetPath, mode),
|
||||
rename: (fromPath, toPath) => fs.promises.rename(fromPath, toPath),
|
||||
unlink: async (targetPath) => {
|
||||
await fs.promises.unlink(targetPath).catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateLauncherAtPath(options: {
|
||||
launcherPath: string;
|
||||
assetUrl: string;
|
||||
expectedSha256: string;
|
||||
download: () => Promise<Buffer>;
|
||||
fs?: LauncherUpdateFileSystem;
|
||||
}): Promise<LauncherUpdateResult> {
|
||||
const fsDeps = options.fs ?? defaultFs();
|
||||
let stat: StatLike;
|
||||
try {
|
||||
stat = await fsDeps.stat(options.launcherPath);
|
||||
} catch {
|
||||
return { status: 'not-found', path: options.launcherPath };
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
return { status: 'skipped', path: options.launcherPath, message: 'Launcher is not a file.' };
|
||||
}
|
||||
|
||||
const existing = await fsDeps.readFile(options.launcherPath);
|
||||
if (!looksLikeSubminerLauncher(existing)) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
path: options.launcherPath,
|
||||
message: 'Existing executable does not look like a SubMiner launcher.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await fsDeps.access(options.launcherPath);
|
||||
} catch {
|
||||
return {
|
||||
status: 'protected',
|
||||
path: options.launcherPath,
|
||||
command: buildProtectedLauncherUpdateCommand(options.assetUrl, options.launcherPath),
|
||||
};
|
||||
}
|
||||
|
||||
const data = await options.download();
|
||||
const actualSha256 = sha256(data);
|
||||
if (actualSha256 !== options.expectedSha256.toLowerCase()) {
|
||||
return {
|
||||
status: 'hash-mismatch',
|
||||
path: options.launcherPath,
|
||||
message: `Expected ${options.expectedSha256}, got ${actualSha256}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const tempPath = path.join(path.dirname(options.launcherPath), '.subminer.update');
|
||||
try {
|
||||
await fsDeps.writeFile(tempPath, data);
|
||||
await fsDeps.chmod(tempPath, stat.mode ? stat.mode & 0o777 : 0o755);
|
||||
await fsDeps.rename(tempPath, options.launcherPath);
|
||||
return { status: 'updated', path: options.launcherPath };
|
||||
} catch (error) {
|
||||
await fsDeps.unlink(tempPath);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectLauncherCandidates(options: {
|
||||
platform: NodeJS.Platform;
|
||||
homeDir: string;
|
||||
explicitPath?: string;
|
||||
}): string[] {
|
||||
const candidates: string[] = [];
|
||||
if (options.explicitPath) candidates.push(options.explicitPath);
|
||||
if (options.platform === 'darwin') {
|
||||
candidates.push('/usr/local/bin/subminer', '/opt/homebrew/bin/subminer');
|
||||
candidates.push(path.join(options.homeDir, '.local/bin/subminer'));
|
||||
} else if (options.platform === 'linux') {
|
||||
candidates.push(path.join(options.homeDir, '.local/bin/subminer'));
|
||||
candidates.push('/usr/local/bin/subminer', '/usr/bin/subminer');
|
||||
}
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
export async function updateLauncherFromRelease(options: {
|
||||
release: GitHubRelease | null;
|
||||
sha256Sums: Map<string, string>;
|
||||
launcherPath?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
downloadAsset: (url: string) => Promise<Buffer>;
|
||||
exists?: (targetPath: string) => boolean;
|
||||
}): Promise<LauncherUpdateResult> {
|
||||
if (!options.release) return { status: 'missing-asset', message: 'No release found.' };
|
||||
const asset = findReleaseAsset(options.release, 'subminer');
|
||||
if (!asset) return { status: 'missing-asset', message: 'Release has no subminer asset.' };
|
||||
const expectedSha256 = options.sha256Sums.get('subminer');
|
||||
if (!expectedSha256) {
|
||||
return { status: 'missing-asset', message: 'SHA256SUMS.txt has no subminer entry.' };
|
||||
}
|
||||
|
||||
const exists = options.exists ?? fs.existsSync;
|
||||
const candidates = detectLauncherCandidates({
|
||||
platform: options.platform ?? process.platform,
|
||||
homeDir: options.homeDir ?? os.homedir(),
|
||||
explicitPath: options.launcherPath,
|
||||
});
|
||||
const targetPath = candidates.find((candidate) => exists(candidate));
|
||||
if (!targetPath) return { status: 'not-found', message: 'No installed launcher detected.' };
|
||||
|
||||
return await updateLauncherAtPath({
|
||||
launcherPath: targetPath,
|
||||
assetUrl: asset.browser_download_url,
|
||||
expectedSha256,
|
||||
download: () => options.downloadAsset(asset.browser_download_url),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
compareSemverLike,
|
||||
findReleaseAsset,
|
||||
parseSha256Sums,
|
||||
selectLatestStableRelease,
|
||||
} from './release-assets';
|
||||
|
||||
test('parseSha256Sums maps release asset basenames to hashes', () => {
|
||||
const sums = parseSha256Sums(`
|
||||
1111111111111111111111111111111111111111111111111111111111111111 SubMiner.AppImage
|
||||
2222222222222222222222222222222222222222222222222222222222222222 *subminer
|
||||
`);
|
||||
|
||||
assert.equal(
|
||||
sums.get('SubMiner.AppImage'),
|
||||
'1111111111111111111111111111111111111111111111111111111111111111',
|
||||
);
|
||||
assert.equal(
|
||||
sums.get('subminer'),
|
||||
'2222222222222222222222222222222222222222222222222222222222222222',
|
||||
);
|
||||
});
|
||||
|
||||
test('selectLatestStableRelease ignores drafts and prereleases', () => {
|
||||
const release = selectLatestStableRelease([
|
||||
{ tag_name: 'v0.16.0-beta.1', draft: false, prerelease: true, assets: [] },
|
||||
{ tag_name: 'v0.15.0', draft: true, prerelease: false, assets: [] },
|
||||
{ tag_name: 'v0.14.1', draft: false, prerelease: false, assets: [] },
|
||||
]);
|
||||
|
||||
assert.equal(release?.tag_name, 'v0.14.1');
|
||||
});
|
||||
|
||||
test('selectLatestStableRelease can opt into prerelease releases', () => {
|
||||
const release = selectLatestStableRelease(
|
||||
[
|
||||
{ tag_name: 'v0.16.0-beta.1', draft: false, prerelease: true, assets: [] },
|
||||
{ tag_name: 'v0.15.0', draft: false, prerelease: false, assets: [] },
|
||||
],
|
||||
'prerelease',
|
||||
);
|
||||
|
||||
assert.equal(release?.tag_name, 'v0.16.0-beta.1');
|
||||
});
|
||||
|
||||
test('compareSemverLike orders prerelease identifiers within the same base version', () => {
|
||||
assert.equal(compareSemverLike('0.15.0-beta.2', '0.15.0-beta.1') > 0, true);
|
||||
assert.equal(compareSemverLike('0.15.0-rc.1', '0.15.0-beta.2') > 0, true);
|
||||
assert.equal(compareSemverLike('0.15.0', '0.15.0-rc.1') > 0, true);
|
||||
});
|
||||
|
||||
test('findReleaseAsset finds exact asset names only', () => {
|
||||
const release = {
|
||||
tag_name: 'v0.14.1',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{ name: 'subminer', browser_download_url: 'https://example.test/subminer' },
|
||||
{ name: 'subminer-assets.tar.gz', browser_download_url: 'https://example.test/assets' },
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
findReleaseAsset(release, 'subminer')?.browser_download_url,
|
||||
'https://example.test/subminer',
|
||||
);
|
||||
assert.equal(findReleaseAsset(release, 'latest.yml'), null);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { UpdateChannel } from '../../../types/config';
|
||||
|
||||
export interface GitHubReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface GitHubRelease {
|
||||
tag_name: string;
|
||||
name?: string;
|
||||
draft?: boolean;
|
||||
prerelease?: boolean;
|
||||
html_url?: string;
|
||||
assets: GitHubReleaseAsset[];
|
||||
}
|
||||
|
||||
export interface FetchResponseLike {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
json: () => Promise<unknown>;
|
||||
text: () => Promise<string>;
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
}
|
||||
|
||||
export type FetchLike = (url: string, init?: Record<string, unknown>) => Promise<FetchResponseLike>;
|
||||
|
||||
export function parseSha256Sums(text: string): Map<string, string> {
|
||||
const sums = new Map<string, string>();
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
|
||||
if (!match) continue;
|
||||
const [, hash, name] = match;
|
||||
if (!hash || !name) continue;
|
||||
sums.set(name.trim().split(/[\\/]/).pop() ?? name.trim(), hash.toLowerCase());
|
||||
}
|
||||
return sums;
|
||||
}
|
||||
|
||||
export function selectLatestStableRelease(
|
||||
releases: GitHubRelease[],
|
||||
channel: UpdateChannel = 'stable',
|
||||
): GitHubRelease | null {
|
||||
return (
|
||||
releases.find(
|
||||
(release) => !release.draft && (channel === 'prerelease' || !release.prerelease),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function findReleaseAsset(
|
||||
release: Pick<GitHubRelease, 'assets'>,
|
||||
assetName: string,
|
||||
): GitHubReleaseAsset | null {
|
||||
return release.assets.find((asset) => asset.name === assetName) ?? null;
|
||||
}
|
||||
|
||||
function assertRelease(value: unknown): GitHubRelease | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const release = value as Partial<GitHubRelease>;
|
||||
if (typeof release.tag_name !== 'string' || !Array.isArray(release.assets)) return null;
|
||||
return {
|
||||
tag_name: release.tag_name,
|
||||
name: typeof release.name === 'string' ? release.name : undefined,
|
||||
draft: release.draft === true,
|
||||
prerelease: release.prerelease === true,
|
||||
html_url: typeof release.html_url === 'string' ? release.html_url : undefined,
|
||||
assets: release.assets
|
||||
.filter((asset): asset is GitHubReleaseAsset => {
|
||||
const candidate = asset as Partial<GitHubReleaseAsset>;
|
||||
return (
|
||||
typeof candidate.name === 'string' && typeof candidate.browser_download_url === 'string'
|
||||
);
|
||||
})
|
||||
.map((asset) => ({
|
||||
name: asset.name,
|
||||
browser_download_url: asset.browser_download_url,
|
||||
size: typeof asset.size === 'number' ? asset.size : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLatestStableRelease(options: {
|
||||
fetch: FetchLike;
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
channel?: UpdateChannel;
|
||||
}): Promise<GitHubRelease | null> {
|
||||
const owner = options.owner ?? 'ksyasuda';
|
||||
const repo = options.repo ?? 'SubMiner';
|
||||
const response = await options.fetch(`https://api.github.com/repos/${owner}/${repo}/releases`, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'SubMiner updater',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub releases request failed with ${response.status}`);
|
||||
}
|
||||
const body = await response.json();
|
||||
if (!Array.isArray(body)) return null;
|
||||
return selectLatestStableRelease(
|
||||
body.map(assertRelease).filter((item): item is GitHubRelease => item !== null),
|
||||
options.channel,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchReleaseAssetText(fetch: FetchLike, assetUrl: string): Promise<string> {
|
||||
const response = await fetch(assetUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Release asset request failed with ${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
export async function fetchReleaseAssetBuffer(fetch: FetchLike, assetUrl: string): Promise<Buffer> {
|
||||
const response = await fetch(assetUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Release asset request failed with ${response.status}`);
|
||||
}
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(
|
||||
release: Pick<GitHubRelease, 'tag_name'> | null,
|
||||
): string | null {
|
||||
if (!release) return null;
|
||||
return release.tag_name.replace(/^v/i, '');
|
||||
}
|
||||
|
||||
export function compareSemverLike(a: string, b: string): number {
|
||||
const parse = (
|
||||
value: string,
|
||||
): {
|
||||
core: number[];
|
||||
prerelease: Array<number | string>;
|
||||
} => {
|
||||
const normalized = value.replace(/^v/i, '');
|
||||
const [coreText = '', ...prereleaseParts] = normalized.split('-');
|
||||
const core = coreText
|
||||
.split('.')
|
||||
.slice(0, 3)
|
||||
.map((part) => Number.parseInt(part, 10) || 0);
|
||||
while (core.length < 3) core.push(0);
|
||||
const prereleaseText = prereleaseParts.join('-');
|
||||
return {
|
||||
core,
|
||||
prerelease: prereleaseText
|
||||
? prereleaseText.split('.').map((part) => {
|
||||
const numeric = Number.parseInt(part, 10);
|
||||
return /^\d+$/.test(part) ? numeric : part;
|
||||
})
|
||||
: [],
|
||||
};
|
||||
};
|
||||
const left = parse(a);
|
||||
const right = parse(b);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
|
||||
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
|
||||
if (left.prerelease.length === 0) return 1;
|
||||
if (right.prerelease.length === 0) return -1;
|
||||
|
||||
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
const leftPart = left.prerelease[i];
|
||||
const rightPart = right.prerelease[i];
|
||||
if (leftPart === undefined && rightPart === undefined) return 0;
|
||||
if (leftPart === undefined) return -1;
|
||||
if (rightPart === undefined) return 1;
|
||||
if (leftPart === rightPart) continue;
|
||||
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
|
||||
return leftPart - rightPart;
|
||||
}
|
||||
if (typeof leftPart === 'number') return -1;
|
||||
if (typeof rightPart === 'number') return 1;
|
||||
return leftPart > rightPart ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import type { GitHubRelease } from './release-assets';
|
||||
import { findReleaseAsset } from './release-assets';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface SupportAssetsUpdateResult {
|
||||
status: 'updated' | 'skipped' | 'protected' | 'hash-mismatch' | 'missing-asset';
|
||||
path?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export function detectSupportAssetDataDirs(options: {
|
||||
platform: NodeJS.Platform;
|
||||
homeDir: string;
|
||||
xdgDataHome?: string;
|
||||
}): string[] {
|
||||
if (options.platform === 'darwin') {
|
||||
return [
|
||||
path.join(options.homeDir, 'Library/Application Support/SubMiner'),
|
||||
'/usr/local/share/SubMiner',
|
||||
];
|
||||
}
|
||||
if (options.platform === 'linux') {
|
||||
const xdgDataHome = options.xdgDataHome || path.join(options.homeDir, '.local/share');
|
||||
return [path.join(xdgDataHome, 'SubMiner'), '/usr/local/share/SubMiner', '/usr/share/SubMiner'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildProtectedSupportAssetsCommand(assetUrl: string, dataDir: string): string {
|
||||
const quotedDir = shellQuote(dataDir);
|
||||
return [
|
||||
'tmp=$(mktemp -d)',
|
||||
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
|
||||
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
|
||||
`sudo mkdir -p ${quotedDir}/plugin/subminer ${quotedDir}/themes`,
|
||||
`sudo cp -R "$tmp/plugin/subminer/." ${quotedDir}/plugin/subminer/`,
|
||||
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
|
||||
].join(' && ');
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(targetPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async function canWrite(targetPath: string): Promise<boolean> {
|
||||
return await fs.promises
|
||||
.access(targetPath, fs.constants.W_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
export async function updateSupportAssetsFromRelease(options: {
|
||||
release: GitHubRelease | null;
|
||||
sha256Sums: Map<string, string>;
|
||||
downloadAsset: (url: string) => Promise<Buffer>;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
xdgDataHome?: string;
|
||||
}): Promise<SupportAssetsUpdateResult[]> {
|
||||
if (!options.release) return [{ status: 'missing-asset', message: 'No release found.' }];
|
||||
const asset = findReleaseAsset(options.release, 'subminer-assets.tar.gz');
|
||||
if (!asset) return [{ status: 'missing-asset', message: 'Release has no support assets.' }];
|
||||
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
|
||||
if (!expectedSha256) {
|
||||
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no support assets entry.' }];
|
||||
}
|
||||
|
||||
const dataDirs = detectSupportAssetDataDirs({
|
||||
platform: options.platform ?? process.platform,
|
||||
homeDir: options.homeDir ?? os.homedir(),
|
||||
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
|
||||
});
|
||||
const existingDataDirs: string[] = [];
|
||||
for (const dataDir of dataDirs) {
|
||||
const hasPlugin = await pathExists(path.join(dataDir, 'plugin/subminer'));
|
||||
const hasTheme = await pathExists(path.join(dataDir, 'themes/subminer.rasi'));
|
||||
if (hasPlugin || hasTheme) existingDataDirs.push(dataDir);
|
||||
}
|
||||
if (existingDataDirs.length === 0) {
|
||||
return [{ status: 'skipped', message: 'No existing support asset install detected.' }];
|
||||
}
|
||||
|
||||
const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs
|
||||
.filter((dataDir) => !fs.existsSync(dataDir) || !fs.statSync(dataDir).isDirectory())
|
||||
.map((dataDir) => ({
|
||||
status: 'skipped' as const,
|
||||
path: dataDir,
|
||||
message: 'Support asset path is not a directory.',
|
||||
}));
|
||||
const writableDataDirs: string[] = [];
|
||||
for (const dataDir of existingDataDirs) {
|
||||
if (await canWrite(dataDir)) {
|
||||
writableDataDirs.push(dataDir);
|
||||
} else {
|
||||
protectedResults.push({
|
||||
status: 'protected',
|
||||
path: dataDir,
|
||||
command: buildProtectedSupportAssetsCommand(asset.browser_download_url, dataDir),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (writableDataDirs.length === 0) return protectedResults;
|
||||
|
||||
const archive = await options.downloadAsset(asset.browser_download_url);
|
||||
const actualSha256 = sha256(archive);
|
||||
if (actualSha256 !== expectedSha256.toLowerCase()) {
|
||||
return [
|
||||
...protectedResults,
|
||||
{
|
||||
status: 'hash-mismatch',
|
||||
message: `Expected ${expectedSha256}, got ${actualSha256}.`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-assets-'));
|
||||
try {
|
||||
const archivePath = path.join(tempDir, 'subminer-assets.tar.gz');
|
||||
await fs.promises.writeFile(archivePath, archive);
|
||||
await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir]);
|
||||
const results: SupportAssetsUpdateResult[] = [...protectedResults];
|
||||
for (const dataDir of writableDataDirs) {
|
||||
const targetPluginDir = path.join(dataDir, 'plugin/subminer');
|
||||
const targetThemePath = path.join(dataDir, 'themes/subminer.rasi');
|
||||
if (await pathExists(targetPluginDir)) {
|
||||
await fs.promises.mkdir(targetPluginDir, { recursive: true });
|
||||
await fs.promises.cp(path.join(tempDir, 'plugin/subminer'), targetPluginDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
if (await pathExists(targetThemePath)) {
|
||||
await fs.promises.mkdir(path.dirname(targetThemePath), { recursive: true });
|
||||
await fs.promises.copyFile(
|
||||
path.join(tempDir, 'assets/themes/subminer.rasi'),
|
||||
targetThemePath,
|
||||
);
|
||||
}
|
||||
results.push({ status: 'updated', path: dataDir });
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { CliArgs } from '../../../cli/args';
|
||||
import { runUpdateCliCommand } from './update-cli-command';
|
||||
|
||||
test('runUpdateCliCommand writes launcher response for second-instance update handoff', async () => {
|
||||
const writes: Array<{ path: string; payload: unknown }> = [];
|
||||
|
||||
await runUpdateCliCommand(
|
||||
{
|
||||
update: true,
|
||||
updateLauncherPath: '/home/kyle/.local/bin/subminer',
|
||||
updateResponsePath: '/tmp/subminer-update-response.json',
|
||||
} as CliArgs,
|
||||
'second-instance',
|
||||
{
|
||||
checkForUpdates: async (request) => {
|
||||
assert.deepEqual(request, {
|
||||
source: 'launcher',
|
||||
launcherPath: '/home/kyle/.local/bin/subminer',
|
||||
});
|
||||
return { status: 'up-to-date', version: '0.15.0' };
|
||||
},
|
||||
writeResponse: (responsePath, payload) => {
|
||||
writes.push({ path: responsePath, payload });
|
||||
},
|
||||
logWarn: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
path: '/tmp/subminer-update-response.json',
|
||||
payload: { ok: true, status: 'up-to-date', version: '0.15.0' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { CliArgs, CliCommandSource } from '../../../cli/args';
|
||||
import type { UpdateCheckRequest, UpdateCheckResult } from './update-service';
|
||||
|
||||
export interface UpdateCliCommandResponse {
|
||||
ok: boolean;
|
||||
status?: UpdateCheckResult['status'];
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCliCommandDeps {
|
||||
checkForUpdates: (request: UpdateCheckRequest) => Promise<UpdateCheckResult>;
|
||||
writeResponse: (responsePath: string, payload: UpdateCliCommandResponse) => void;
|
||||
logWarn: (message: string, error?: unknown) => void;
|
||||
}
|
||||
|
||||
export function writeUpdateCliCommandResponse(
|
||||
responsePath: string,
|
||||
payload: UpdateCliCommandResponse,
|
||||
): void {
|
||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||
fs.writeFileSync(responsePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function responseFromResult(result: UpdateCheckResult): UpdateCliCommandResponse {
|
||||
const response: UpdateCliCommandResponse = {
|
||||
ok: result.status !== 'failed',
|
||||
status: result.status,
|
||||
};
|
||||
if (result.version !== undefined) response.version = result.version;
|
||||
if (result.error !== undefined) response.error = result.error;
|
||||
return response;
|
||||
}
|
||||
|
||||
function writeResponseSafe(
|
||||
responsePath: string | undefined,
|
||||
payload: UpdateCliCommandResponse,
|
||||
deps: Pick<UpdateCliCommandDeps, 'writeResponse' | 'logWarn'>,
|
||||
): void {
|
||||
if (!responsePath) return;
|
||||
try {
|
||||
deps.writeResponse(responsePath, payload);
|
||||
} catch (error) {
|
||||
deps.logWarn(`Failed to write update response: ${responsePath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runUpdateCliCommand(
|
||||
args: Pick<CliArgs, 'updateLauncherPath' | 'updateResponsePath'>,
|
||||
_source: CliCommandSource,
|
||||
deps: UpdateCliCommandDeps,
|
||||
): Promise<UpdateCheckResult> {
|
||||
try {
|
||||
const result = await deps.checkForUpdates({
|
||||
source: args.updateLauncherPath ? 'launcher' : 'manual',
|
||||
launcherPath: args.updateLauncherPath,
|
||||
});
|
||||
writeResponseSafe(args.updateResponsePath, responseFromResult(result), deps);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
writeResponseSafe(
|
||||
args.updateResponsePath,
|
||||
{ ok: false, status: 'failed', error: message },
|
||||
deps,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export type UpdateAvailableChoice = 'update' | 'close';
|
||||
export type RestartChoice = 'restart' | 'later';
|
||||
|
||||
export interface MessageBoxResultLike {
|
||||
response: number;
|
||||
}
|
||||
|
||||
export type ShowMessageBox = (options: {
|
||||
type?: 'info' | 'warning' | 'error' | 'question';
|
||||
title?: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
buttons?: string[];
|
||||
defaultId?: number;
|
||||
cancelId?: number;
|
||||
}) => Promise<MessageBoxResultLike>;
|
||||
|
||||
export async function showNoUpdateDialog(
|
||||
showMessageBox: ShowMessageBox,
|
||||
version: string,
|
||||
): Promise<void> {
|
||||
await showMessageBox({
|
||||
type: 'info',
|
||||
title: 'SubMiner Updates',
|
||||
message: `SubMiner is up to date (v${version})`,
|
||||
buttons: ['Close'],
|
||||
});
|
||||
}
|
||||
|
||||
export async function showUpdateAvailableDialog(
|
||||
showMessageBox: ShowMessageBox,
|
||||
version: string,
|
||||
): Promise<UpdateAvailableChoice> {
|
||||
const result = await showMessageBox({
|
||||
type: 'question',
|
||||
title: 'SubMiner Updates',
|
||||
message: `SubMiner v${version} is available`,
|
||||
buttons: ['Update', 'Close'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
return result.response === 0 ? 'update' : 'close';
|
||||
}
|
||||
|
||||
export async function showRestartDialog(showMessageBox: ShowMessageBox): Promise<RestartChoice> {
|
||||
const result = await showMessageBox({
|
||||
type: 'question',
|
||||
title: 'SubMiner Updates',
|
||||
message: 'Restart to update',
|
||||
buttons: ['Restart', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
return result.response === 0 ? 'restart' : 'later';
|
||||
}
|
||||
|
||||
export async function showUpdateFailedDialog(
|
||||
showMessageBox: ShowMessageBox,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
await showMessageBox({
|
||||
type: 'error',
|
||||
title: 'SubMiner Updates',
|
||||
message: 'Update check failed',
|
||||
detail: message,
|
||||
buttons: ['Close'],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { notifyUpdateAvailable } from './update-notifications';
|
||||
|
||||
test('notifyUpdateAvailable routes system and osd notifications from config', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = {
|
||||
showSystemNotification: (title: string, body: string) => {
|
||||
calls.push(`system:${title}:${body}`);
|
||||
},
|
||||
showOsdNotification: async (message: string) => {
|
||||
calls.push(`osd:${message}`);
|
||||
},
|
||||
log: (message: string) => {
|
||||
calls.push(`log:${message}`);
|
||||
},
|
||||
};
|
||||
|
||||
await notifyUpdateAvailable({ notificationType: 'system', version: '0.15.0' }, deps);
|
||||
await notifyUpdateAvailable({ notificationType: 'both', version: '0.15.0' }, deps);
|
||||
await notifyUpdateAvailable({ notificationType: 'none', version: '0.15.0' }, deps);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'system:SubMiner update available:SubMiner v0.15.0 is available',
|
||||
'system:SubMiner update available:SubMiner v0.15.0 is available',
|
||||
'osd:SubMiner v0.15.0 is available',
|
||||
]);
|
||||
});
|
||||
|
||||
test('notifyUpdateAvailable logs osd fallback when overlay notification fails', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await notifyUpdateAvailable(
|
||||
{ notificationType: 'osd', version: '0.15.0' },
|
||||
{
|
||||
showSystemNotification: () => {
|
||||
calls.push('system');
|
||||
},
|
||||
showOsdNotification: async () => {
|
||||
throw new Error('mpv disconnected');
|
||||
},
|
||||
log: (message) => {
|
||||
calls.push(message);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { UpdateNotificationType } from '../../../types/config';
|
||||
|
||||
export interface UpdateNotificationDeps {
|
||||
showSystemNotification: (title: string, body: string) => void;
|
||||
showOsdNotification: (message: string) => void | Promise<void>;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
export async function notifyUpdateAvailable(
|
||||
options: { notificationType: UpdateNotificationType; version: string },
|
||||
deps: UpdateNotificationDeps,
|
||||
): Promise<void> {
|
||||
if (options.notificationType === 'none') return;
|
||||
|
||||
const message = `SubMiner v${options.version} is available`;
|
||||
if (options.notificationType === 'system' || options.notificationType === 'both') {
|
||||
deps.showSystemNotification('SubMiner update available', message);
|
||||
}
|
||||
if (options.notificationType === 'osd' || options.notificationType === 'both') {
|
||||
try {
|
||||
await deps.showOsdNotification(message);
|
||||
} catch (error) {
|
||||
deps.log(`Update OSD notification failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createUpdateService, type UpdateServiceDeps, type UpdateState } from './update-service';
|
||||
|
||||
function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
|
||||
let state: UpdateState = {};
|
||||
const calls: string[] = [];
|
||||
const deps: UpdateServiceDeps = {
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
checkIntervalHours: 24,
|
||||
notificationType: 'system',
|
||||
channel: 'stable',
|
||||
}),
|
||||
getCurrentVersion: () => '0.14.0',
|
||||
now: () => 1_000_000,
|
||||
readState: async () => state,
|
||||
writeState: async (nextState) => {
|
||||
state = nextState;
|
||||
calls.push(`state:${JSON.stringify(nextState)}`);
|
||||
},
|
||||
checkAppUpdate: async () => ({ available: false, version: '0.14.0' }),
|
||||
fetchLatestStableRelease: async () => ({
|
||||
tag_name: 'v0.14.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
}),
|
||||
updateLauncher: async () => ({ status: 'skipped' }),
|
||||
showNoUpdateDialog: async (version) => {
|
||||
calls.push(`no-update:${version}`);
|
||||
},
|
||||
showUpdateAvailableDialog: async (version) => {
|
||||
calls.push(`available-dialog:${version}`);
|
||||
return 'close';
|
||||
},
|
||||
showUpdateFailedDialog: async (message) => {
|
||||
calls.push(`failed:${message}`);
|
||||
},
|
||||
downloadAppUpdate: async () => {
|
||||
calls.push('download');
|
||||
},
|
||||
showRestartDialog: async () => {
|
||||
calls.push('restart-dialog');
|
||||
return 'later';
|
||||
},
|
||||
quitAndInstall: () => calls.push('quit-install'),
|
||||
notifyUpdateAvailable: async (version) => {
|
||||
calls.push(`notify:${version}`);
|
||||
},
|
||||
log: (message) => calls.push(`log:${message}`),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
deps,
|
||||
calls,
|
||||
getState: () => state,
|
||||
setState: (nextState: UpdateState) => (state = nextState),
|
||||
};
|
||||
}
|
||||
|
||||
test('manual update check shows latest-version dialog when already current', async () => {
|
||||
const { deps, calls } = createDeps();
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'up-to-date');
|
||||
assert.deepEqual(calls, ['no-update:0.14.0']);
|
||||
});
|
||||
|
||||
test('manual update check falls back to GitHub release when app metadata is unavailable', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
checkAppUpdate: async () => {
|
||||
throw new Error('latest-linux.yml missing');
|
||||
},
|
||||
fetchLatestStableRelease: async () => ({
|
||||
tag_name: 'v0.15.0',
|
||||
prerelease: false,
|
||||
draft: false,
|
||||
assets: [],
|
||||
}),
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'update-available');
|
||||
assert.deepEqual(calls, ['available-dialog:0.15.0']);
|
||||
});
|
||||
|
||||
test('automatic update check skips inside configured interval', async () => {
|
||||
const { deps, calls, setState } = createDeps();
|
||||
setState({ lastAutomaticCheckAt: 1_000_000 - 60 * 60 * 1000 });
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'automatic' });
|
||||
|
||||
assert.equal(result.status, 'skipped');
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('automatic update check notifies once per version and records check time', async () => {
|
||||
const { deps, calls, getState } = createDeps({
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const first = await service.checkForUpdates({ source: 'automatic' });
|
||||
const second = await service.checkForUpdates({ source: 'automatic', force: true });
|
||||
|
||||
assert.equal(first.status, 'update-available');
|
||||
assert.equal(second.status, 'update-available');
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call === 'notify:0.15.0'),
|
||||
['notify:0.15.0'],
|
||||
);
|
||||
assert.equal(getState().lastNotifiedVersion, '0.15.0');
|
||||
assert.equal(getState().lastAutomaticCheckAt, 1_000_000);
|
||||
});
|
||||
|
||||
test('concurrent update checks share one in-flight check', async () => {
|
||||
let checkCount = 0;
|
||||
let resolveCheck: (value: { available: boolean; version: string }) => void = () => {};
|
||||
const { deps } = createDeps({
|
||||
checkAppUpdate: () =>
|
||||
new Promise((resolve) => {
|
||||
checkCount += 1;
|
||||
resolveCheck = resolve;
|
||||
}),
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
const first = service.checkForUpdates({ source: 'manual' });
|
||||
const second = service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
await Promise.resolve();
|
||||
resolveCheck({ available: false, version: '0.14.0' });
|
||||
await Promise.all([first, second]);
|
||||
|
||||
assert.equal(checkCount, 1);
|
||||
});
|
||||
|
||||
test('manual prerelease update check uses prerelease release and launcher channel', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
getConfig: () => ({
|
||||
enabled: true,
|
||||
checkIntervalHours: 24,
|
||||
notificationType: 'system',
|
||||
channel: 'prerelease',
|
||||
}),
|
||||
checkAppUpdate: async () => ({ available: true, version: '0.15.0-beta.1' }),
|
||||
fetchLatestStableRelease: async (channel) => {
|
||||
calls.push(`fetch:${channel}`);
|
||||
return {
|
||||
tag_name: 'v0.15.0-beta.1',
|
||||
prerelease: true,
|
||||
draft: false,
|
||||
assets: [],
|
||||
};
|
||||
},
|
||||
showUpdateAvailableDialog: async (version) => {
|
||||
calls.push(`available-dialog:${version}`);
|
||||
return 'update';
|
||||
},
|
||||
updateLauncher: async (_launcherPath, channel) => {
|
||||
calls.push(`launcher:${channel}`);
|
||||
return { status: 'skipped' };
|
||||
},
|
||||
});
|
||||
const service = createUpdateService(deps);
|
||||
|
||||
const result = await service.checkForUpdates({ source: 'manual' });
|
||||
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.deepEqual(calls, [
|
||||
'fetch:prerelease',
|
||||
'available-dialog:0.15.0-beta.1',
|
||||
'download',
|
||||
'launcher:prerelease',
|
||||
'restart-dialog',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { UpdateChannel, UpdatesConfig } from '../../../types/config';
|
||||
import type { GitHubRelease } from './release-assets';
|
||||
import { compareSemverLike, parseReleaseVersion } from './release-assets';
|
||||
|
||||
export interface UpdateState {
|
||||
lastAutomaticCheckAt?: number;
|
||||
lastNotifiedVersion?: string;
|
||||
}
|
||||
|
||||
export type UpdateCheckSource = 'manual' | 'automatic' | 'launcher';
|
||||
|
||||
export interface UpdateCheckRequest {
|
||||
source: UpdateCheckSource;
|
||||
force?: boolean;
|
||||
launcherPath?: string;
|
||||
}
|
||||
|
||||
export type UpdateCheckStatus =
|
||||
| 'up-to-date'
|
||||
| 'update-available'
|
||||
| 'updated'
|
||||
| 'skipped'
|
||||
| 'failed';
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
status: UpdateCheckStatus;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpdateServiceDeps {
|
||||
getConfig: () => Required<UpdatesConfig>;
|
||||
getCurrentVersion: () => string;
|
||||
now: () => number;
|
||||
readState: () => Promise<UpdateState>;
|
||||
writeState: (state: UpdateState) => Promise<void>;
|
||||
checkAppUpdate: (
|
||||
channel: UpdateChannel,
|
||||
) => Promise<{ available: boolean; version: string; canUpdate?: boolean }>;
|
||||
fetchLatestStableRelease: (channel: UpdateChannel) => Promise<GitHubRelease | null>;
|
||||
updateLauncher: (
|
||||
launcherPath?: string,
|
||||
channel?: UpdateChannel,
|
||||
) => Promise<{ status: string; command?: string }>;
|
||||
showNoUpdateDialog: (version: string) => Promise<void>;
|
||||
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
|
||||
showUpdateFailedDialog: (message: string) => Promise<void>;
|
||||
downloadAppUpdate: () => Promise<void>;
|
||||
showRestartDialog: () => Promise<'restart' | 'later'>;
|
||||
quitAndInstall: () => void;
|
||||
notifyUpdateAvailable: (version: string) => Promise<void>;
|
||||
log: (message: string) => void;
|
||||
setTimeout?: (callback: () => void, delayMs: number) => unknown;
|
||||
setInterval?: (callback: () => void, delayMs: number) => unknown;
|
||||
}
|
||||
|
||||
function getBestLatestVersion(
|
||||
currentVersion: string,
|
||||
appUpdate: { available: boolean; version: string },
|
||||
release: GitHubRelease | null,
|
||||
): { available: boolean; version: string } {
|
||||
const releaseVersion = parseReleaseVersion(release);
|
||||
const candidates = [appUpdate.version, releaseVersion].filter(
|
||||
(value): value is string => typeof value === 'string' && value.length > 0,
|
||||
);
|
||||
const latest = candidates.reduce(
|
||||
(best, candidate) => (compareSemverLike(candidate, best) > 0 ? candidate : best),
|
||||
currentVersion,
|
||||
);
|
||||
return {
|
||||
available: appUpdate.available || compareSemverLike(latest, currentVersion) > 0,
|
||||
version: latest,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldSkipAutomaticCheck(
|
||||
config: Required<UpdatesConfig>,
|
||||
state: UpdateState,
|
||||
now: number,
|
||||
) {
|
||||
if (!config.enabled) return true;
|
||||
if (!state.lastAutomaticCheckAt) return false;
|
||||
const intervalMs = Math.max(1, config.checkIntervalHours) * 60 * 60 * 1000;
|
||||
return now - state.lastAutomaticCheckAt < intervalMs;
|
||||
}
|
||||
|
||||
function summarizeError(error: unknown): string {
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const firstLine = raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
return firstLine ?? 'unknown error';
|
||||
}
|
||||
|
||||
export function createUpdateService(deps: UpdateServiceDeps) {
|
||||
let inFlight: Promise<UpdateCheckResult> | null = null;
|
||||
|
||||
async function runCheck(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||
const now = deps.now();
|
||||
const config = deps.getConfig();
|
||||
const channel = config.channel;
|
||||
const state = await deps.readState();
|
||||
const isAutomatic = request.source === 'automatic';
|
||||
|
||||
if (isAutomatic && !request.force && shouldSkipAutomaticCheck(config, state, now)) {
|
||||
return { status: 'skipped' };
|
||||
}
|
||||
|
||||
try {
|
||||
const [appUpdate, release] = await Promise.all([
|
||||
deps.checkAppUpdate(channel).catch((error) => {
|
||||
if (isAutomatic) {
|
||||
deps.log(`App update metadata check failed: ${summarizeError(error)}`);
|
||||
}
|
||||
return {
|
||||
available: false,
|
||||
version: deps.getCurrentVersion(),
|
||||
canUpdate: false,
|
||||
};
|
||||
}),
|
||||
deps.fetchLatestStableRelease(channel).catch((error) => {
|
||||
deps.log(`GitHub release update check failed: ${(error as Error).message}`);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
const currentVersion = deps.getCurrentVersion();
|
||||
const latest = getBestLatestVersion(currentVersion, appUpdate, release);
|
||||
|
||||
if (isAutomatic) {
|
||||
const nextState: UpdateState = {
|
||||
...state,
|
||||
lastAutomaticCheckAt: now,
|
||||
};
|
||||
if (latest.available && state.lastNotifiedVersion !== latest.version) {
|
||||
await deps.notifyUpdateAvailable(latest.version);
|
||||
nextState.lastNotifiedVersion = latest.version;
|
||||
}
|
||||
await deps.writeState(nextState);
|
||||
}
|
||||
|
||||
if (!latest.available) {
|
||||
if (!isAutomatic) {
|
||||
await deps.showNoUpdateDialog(currentVersion);
|
||||
}
|
||||
return { status: 'up-to-date', version: currentVersion };
|
||||
}
|
||||
|
||||
if (isAutomatic) {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
}
|
||||
|
||||
const choice = await deps.showUpdateAvailableDialog(latest.version);
|
||||
if (choice === 'close') {
|
||||
return { status: 'update-available', version: latest.version };
|
||||
}
|
||||
|
||||
if (appUpdate.available && appUpdate.canUpdate !== false) {
|
||||
await deps.downloadAppUpdate();
|
||||
}
|
||||
const launcherResult = await deps.updateLauncher(request.launcherPath, channel);
|
||||
if (launcherResult.status === 'protected' && launcherResult.command) {
|
||||
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
|
||||
}
|
||||
|
||||
const restartChoice = await deps.showRestartDialog();
|
||||
if (restartChoice === 'restart') {
|
||||
deps.quitAndInstall();
|
||||
}
|
||||
return { status: 'updated', version: latest.version };
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
if (isAutomatic) {
|
||||
deps.log(`Automatic update check failed: ${message}`);
|
||||
} else {
|
||||
await deps.showUpdateFailedDialog(message);
|
||||
}
|
||||
return { status: 'failed', error: message };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = runCheck(request).finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return inFlight;
|
||||
},
|
||||
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
|
||||
const setTimeoutFn = deps.setTimeout ?? setTimeout;
|
||||
const setIntervalFn = deps.setInterval ?? setInterval;
|
||||
const startupDelayMs = options.startupDelayMs ?? 15_000;
|
||||
const pollIntervalMs = options.pollIntervalMs ?? 60 * 60 * 1000;
|
||||
setTimeoutFn(() => {
|
||||
void this.checkForUpdates({ source: 'automatic' });
|
||||
}, startupDelayMs);
|
||||
setIntervalFn(() => {
|
||||
void this.checkForUpdates({ source: 'automatic' });
|
||||
}, pollIntervalMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createFileUpdateStateStore(statePath: string): {
|
||||
readState: () => Promise<UpdateState>;
|
||||
writeState: (state: UpdateState) => Promise<void>;
|
||||
} {
|
||||
return {
|
||||
async readState(): Promise<UpdateState> {
|
||||
try {
|
||||
return JSON.parse(await fs.promises.readFile(statePath, 'utf8')) as UpdateState;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
async writeState(state: UpdateState): Promise<void> {
|
||||
await fs.promises.mkdir(path.dirname(statePath), { recursive: true });
|
||||
await fs.promises.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -41,3 +41,107 @@ test('yomitan opener opens settings window when extension is available', async (
|
||||
await Promise.resolve();
|
||||
assert.equal(forwardedSession, yomitanSession);
|
||||
});
|
||||
|
||||
test('yomitan opener opens settings window without a dedicated session', async () => {
|
||||
let forwardedSession: unknown = 'unset';
|
||||
const logs: string[] = [];
|
||||
const openSettings = createOpenYomitanSettingsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||
openYomitanSettingsWindow: ({ yomitanSession: nextSession }) => {
|
||||
forwardedSession = nextSession;
|
||||
},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
getYomitanSession: () => null,
|
||||
logWarn: (message) => logs.push(message),
|
||||
logError: () => logs.push('error'),
|
||||
});
|
||||
|
||||
openSettings();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(forwardedSession, null);
|
||||
assert.deepEqual(logs, []);
|
||||
});
|
||||
|
||||
test('yomitan opener does not start settings-triggered extension load while startup load is in flight', async () => {
|
||||
let ensureCalled = false;
|
||||
const logs: string[] = [];
|
||||
const startupLoad = new Promise<unknown>(() => {});
|
||||
const openSettings = createOpenYomitanSettingsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
ensureCalled = true;
|
||||
return { id: 'ext' };
|
||||
},
|
||||
getYomitanExtension: () => null,
|
||||
getYomitanExtensionLoadInFlight: () => startupLoad,
|
||||
openYomitanSettingsWindow: () => {
|
||||
throw new Error('should not open while startup load is in flight');
|
||||
},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
logWarn: (message) => logs.push(message),
|
||||
logError: () => logs.push('error'),
|
||||
});
|
||||
|
||||
openSettings();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(ensureCalled, false);
|
||||
assert.deepEqual(logs, [
|
||||
'Yomitan settings requested while Yomitan is still loading. Try again in a few seconds.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('yomitan opener uses loaded extension from app state without calling loader', async () => {
|
||||
let forwardedExtension: { id: string } | null = null;
|
||||
const appStateExtension = { id: 'loaded-ext' };
|
||||
const openSettings = createOpenYomitanSettingsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
throw new Error('should not load extension from settings click');
|
||||
},
|
||||
getYomitanExtension: () => appStateExtension,
|
||||
getYomitanExtensionLoadInFlight: () => null,
|
||||
openYomitanSettingsWindow: ({ yomitanExt }) => {
|
||||
forwardedExtension = yomitanExt as { id: string };
|
||||
},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
openSettings();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(forwardedExtension, appStateExtension);
|
||||
});
|
||||
|
||||
test('yomitan opener warns instead of starting a settings-triggered load when extension is not ready', async () => {
|
||||
let ensureCalled = false;
|
||||
const logs: string[] = [];
|
||||
const openSettings = createOpenYomitanSettingsHandler({
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
ensureCalled = true;
|
||||
return { id: 'ext' };
|
||||
},
|
||||
getYomitanExtension: () => null,
|
||||
getYomitanExtensionLoadInFlight: () => null,
|
||||
openYomitanSettingsWindow: () => {
|
||||
throw new Error('should not open before extension is ready');
|
||||
},
|
||||
getExistingWindow: () => null,
|
||||
setWindow: () => {},
|
||||
logWarn: (message) => logs.push(message),
|
||||
logError: () => logs.push('error'),
|
||||
});
|
||||
|
||||
openSettings();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(ensureCalled, false);
|
||||
assert.deepEqual(logs, ['Unable to open Yomitan settings: extension is not loaded yet.']);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ type SessionLike = unknown;
|
||||
|
||||
export function createOpenYomitanSettingsHandler(deps: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<YomitanExtensionLike | null>;
|
||||
getYomitanExtension?: () => YomitanExtensionLike | null;
|
||||
getYomitanExtensionLoadInFlight?: () => Promise<unknown> | null;
|
||||
openYomitanSettingsWindow: (params: {
|
||||
yomitanExt: YomitanExtensionLike;
|
||||
getExistingWindow: () => BrowserWindowLike | null;
|
||||
@@ -19,16 +21,35 @@ export function createOpenYomitanSettingsHandler(deps: {
|
||||
}) {
|
||||
return (): void => {
|
||||
void (async () => {
|
||||
if (deps.getYomitanExtension) {
|
||||
const loadedExtension = deps.getYomitanExtension();
|
||||
if (!loadedExtension) {
|
||||
if (deps.getYomitanExtensionLoadInFlight?.()) {
|
||||
deps.logWarn(
|
||||
'Yomitan settings requested while Yomitan is still loading. Try again in a few seconds.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
deps.logWarn('Unable to open Yomitan settings: extension is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? null;
|
||||
deps.openYomitanSettingsWindow({
|
||||
yomitanExt: loadedExtension,
|
||||
getExistingWindow: deps.getExistingWindow,
|
||||
setWindow: deps.setWindow,
|
||||
yomitanSession,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const extension = await deps.ensureYomitanExtensionLoaded();
|
||||
if (!extension) {
|
||||
deps.logWarn('Unable to open Yomitan settings: extension failed to load.');
|
||||
return;
|
||||
}
|
||||
const yomitanSession = deps.getYomitanSession?.() ?? null;
|
||||
if (!yomitanSession) {
|
||||
deps.logWarn('Unable to open Yomitan settings: Yomitan session is unavailable.');
|
||||
return;
|
||||
}
|
||||
deps.openYomitanSettingsWindow({
|
||||
yomitanExt: extension,
|
||||
getExistingWindow: deps.getExistingWindow,
|
||||
|
||||
@@ -36,14 +36,14 @@ test('yomitan settings runtime composes opener with built deps', async () => {
|
||||
assert.deepEqual(calls, ['open-window:session']);
|
||||
});
|
||||
|
||||
test('yomitan settings runtime warns and does not open when no yomitan session is available', async () => {
|
||||
test('yomitan settings runtime opens with default session when no yomitan session is available', async () => {
|
||||
let existingWindow: { id: string } | null = null;
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||
openYomitanSettingsWindow: () => {
|
||||
calls.push('open-window');
|
||||
openYomitanSettingsWindow: ({ yomitanSession }) => {
|
||||
calls.push(`open-window:${yomitanSession === null ? 'default-session' : 'custom-session'}`);
|
||||
},
|
||||
getExistingWindow: () => existingWindow as never,
|
||||
setWindow: (window) => {
|
||||
@@ -58,7 +58,5 @@ test('yomitan settings runtime warns and does not open when no yomitan session i
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(existingWindow, null);
|
||||
assert.deepEqual(calls, [
|
||||
'warn:Unable to open Yomitan settings: Yomitan session is unavailable.',
|
||||
]);
|
||||
assert.deepEqual(calls, ['open-window:default-session']);
|
||||
});
|
||||
|
||||
@@ -68,11 +68,11 @@ test('prerelease workflow builds and uploads all release platforms', () => {
|
||||
test('prerelease workflow publishes the same release assets as the stable workflow', () => {
|
||||
assert.match(
|
||||
prereleaseWorkflow,
|
||||
/files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/,
|
||||
/files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz release\/\*\.yml release\/\*\.blockmap dist\/launcher\/subminer\)/,
|
||||
);
|
||||
assert.match(
|
||||
prereleaseWorkflow,
|
||||
/artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/,
|
||||
/artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/\*\.yml[\s\S]*release\/\*\.blockmap[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,17 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
build?: {
|
||||
afterPack?: string;
|
||||
electronUpdaterCompatibility?: string;
|
||||
files?: string[];
|
||||
extraResources?: Array<{
|
||||
from?: string;
|
||||
to?: string;
|
||||
}>;
|
||||
publish?: Array<{
|
||||
provider?: string;
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -72,14 +82,21 @@ test('release workflow generates release notes from committed changelog output',
|
||||
test('release workflow includes the Windows installer in checksums and uploaded assets', () => {
|
||||
assert.match(
|
||||
releaseWorkflow,
|
||||
/files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/,
|
||||
/files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz release\/\*\.yml release\/\*\.blockmap dist\/launcher\/subminer\)/,
|
||||
);
|
||||
assert.match(
|
||||
releaseWorkflow,
|
||||
/artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/,
|
||||
/artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/\*\.yml[\s\S]*release\/\*\.blockmap[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('release package metadata enables GitHub updater metadata without builder uploads', () => {
|
||||
assert.equal(packageJson.build?.publish?.[0]?.provider, 'github');
|
||||
assert.equal(packageJson.build?.publish?.[0]?.owner, 'ksyasuda');
|
||||
assert.equal(packageJson.build?.publish?.[0]?.repo, 'SubMiner');
|
||||
assert.equal(packageJson.build?.electronUpdaterCompatibility, '>=2.16');
|
||||
});
|
||||
|
||||
test('release workflow writes checksum entries using release asset basenames', () => {
|
||||
assert.match(releaseWorkflow, /: > release\/SHA256SUMS\.txt/);
|
||||
assert.match(releaseWorkflow, /for file in "\$\{files\[@\]\}"; do/);
|
||||
@@ -139,6 +156,16 @@ test('release packaging keeps default file inclusion and excludes large source-o
|
||||
assert.ok(files.includes('!node_modules/@libsql/linux-x64-musl{,/**/*}'));
|
||||
});
|
||||
|
||||
test('release packaging stages generated launcher as an app resource', () => {
|
||||
assert.ok(
|
||||
packageJson.build?.extraResources?.some(
|
||||
(resource) =>
|
||||
resource.from === 'dist/launcher/subminer' && resource.to === 'launcher/subminer',
|
||||
),
|
||||
);
|
||||
assert.match(packageJson.scripts.build ?? '', /bun run build:launcher/);
|
||||
});
|
||||
|
||||
test('config example generation runs directly from source without unrelated bundle prerequisites', () => {
|
||||
assert.equal(
|
||||
packageJson.scripts['generate:config-example'],
|
||||
|
||||
@@ -2,7 +2,12 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import { describeSessionHelpCommand, formatSessionHelpKeybinding } from './session-help.js';
|
||||
import { createRendererState } from '../state.js';
|
||||
import {
|
||||
createSessionHelpModal,
|
||||
describeSessionHelpCommand,
|
||||
formatSessionHelpKeybinding,
|
||||
} from './session-help.js';
|
||||
|
||||
test('session help describes sub-seek commands as subtitle-line navigation', () => {
|
||||
assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle');
|
||||
@@ -24,3 +29,154 @@ test('session help formats bracket keybindings as physical keys', () => {
|
||||
assert.equal(formatSessionHelpKeybinding('Shift+BracketRight'), 'Shift + ]');
|
||||
assert.equal(formatSessionHelpKeybinding('Shift+BracketLeft'), 'Shift + [');
|
||||
});
|
||||
|
||||
function createClassList(initialTokens: string[] = []) {
|
||||
const tokens = new Set(initialTokens);
|
||||
return {
|
||||
add: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.add(entry);
|
||||
},
|
||||
remove: (...entries: string[]) => {
|
||||
for (const entry of entries) tokens.delete(entry);
|
||||
},
|
||||
contains: (entry: string) => tokens.has(entry),
|
||||
};
|
||||
}
|
||||
|
||||
function createElementStub() {
|
||||
return {
|
||||
value: '',
|
||||
textContent: '',
|
||||
innerHTML: '',
|
||||
classList: createClassList(['hidden']),
|
||||
setAttribute: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
focus: () => {},
|
||||
select: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
test('modal-layer session help does not focus hidden main overlay and still closes', async () => {
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
window?: unknown;
|
||||
document?: unknown;
|
||||
HTMLElement?: unknown;
|
||||
Element?: unknown;
|
||||
};
|
||||
const previousWindow = globals.window;
|
||||
const previousDocument = globals.document;
|
||||
const previousHTMLElement = globals.HTMLElement;
|
||||
const previousElement = globals.Element;
|
||||
const focusMainWindowCalls: number[] = [];
|
||||
const notifications: string[] = [];
|
||||
|
||||
try {
|
||||
class TestElement {}
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: TestElement,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Element', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: TestElement,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
focusMainWindow: async () => {
|
||||
focusMainWindowCalls.push(1);
|
||||
},
|
||||
setIgnoreMouseEvents: () => {},
|
||||
notifyOverlayModalClosed: (modal: string) => {
|
||||
notifications.push(modal);
|
||||
},
|
||||
getKeybindings: async () => {
|
||||
throw new Error('mpv unavailable');
|
||||
},
|
||||
getSubtitleStyle: async () => ({}),
|
||||
getConfiguredShortcuts: async () => ({}),
|
||||
},
|
||||
focus: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
setTimeout: (callback: () => void) => setTimeout(callback, 0),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
activeElement: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const state = createRendererState();
|
||||
const modal = createSessionHelpModal(
|
||||
{
|
||||
state,
|
||||
platform: {
|
||||
overlayLayer: 'modal',
|
||||
isModalLayer: true,
|
||||
isLinuxPlatform: false,
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: true,
|
||||
shouldToggleMouseIgnore: false,
|
||||
},
|
||||
dom: {
|
||||
overlay: createElementStub(),
|
||||
sessionHelpModal: createElementStub(),
|
||||
sessionHelpFilter: createElementStub(),
|
||||
sessionHelpContent: createElementStub(),
|
||||
sessionHelpClose: createElementStub(),
|
||||
sessionHelpShortcut: createElementStub(),
|
||||
sessionHelpWarning: createElementStub(),
|
||||
sessionHelpStatus: createElementStub(),
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
modalStateReader: { isAnyModalOpen: () => false },
|
||||
syncSettingsModalSubtitleSuppression: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
modal.openSessionHelpModal({
|
||||
bindingKey: 'KeyH',
|
||||
fallbackUsed: false,
|
||||
fallbackUnavailable: false,
|
||||
});
|
||||
modal.closeSessionHelpModal();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(focusMainWindowCalls, []);
|
||||
assert.deepEqual(notifications, ['session-help']);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: previousDocument,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'HTMLElement', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: previousHTMLElement,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'Element', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: previousElement,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -450,7 +450,9 @@ export function createSessionHelpModal(
|
||||
}
|
||||
|
||||
function focusFallbackTarget(): boolean {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
if (!ctx.platform.isModalLayer) {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
}
|
||||
const items = getItems();
|
||||
const firstItem = items.find((item) => item.offsetParent !== null);
|
||||
if (firstItem) {
|
||||
@@ -526,7 +528,9 @@ export function createSessionHelpModal(
|
||||
}
|
||||
|
||||
function requestOverlayFocus(): void {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
if (!ctx.platform.isModalLayer) {
|
||||
void window.electronAPI.focusMainWindow();
|
||||
}
|
||||
}
|
||||
|
||||
function addPointerFocusListener(): void {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getDefaultConfigDir,
|
||||
getDefaultConfigFilePaths,
|
||||
getSetupStatePath,
|
||||
normalizeSetupState,
|
||||
readSetupState,
|
||||
resolveDefaultMpvInstallPaths,
|
||||
writeSetupState,
|
||||
@@ -102,7 +103,28 @@ test('readSetupState ignores invalid files and round-trips valid state', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('readSetupState migrates v1 state to v3 windows shortcut defaults', () => {
|
||||
test('createDefaultSetupState includes v4 command-line launcher defaults', () => {
|
||||
assert.deepEqual(createDefaultSetupState(), {
|
||||
version: 4,
|
||||
status: 'incomplete',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
bunInstallStatus: 'unknown',
|
||||
launcherInstallStatus: 'unknown',
|
||||
launcherInstallPath: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('readSetupState migrates v1 state to v4 launcher defaults', () => {
|
||||
withTempDir((root) => {
|
||||
const statePath = getSetupStatePath(root);
|
||||
fs.writeFileSync(
|
||||
@@ -119,7 +141,7 @@ test('readSetupState migrates v1 state to v3 windows shortcut defaults', () => {
|
||||
);
|
||||
|
||||
assert.deepEqual(readSetupState(statePath), {
|
||||
version: 3,
|
||||
version: 4,
|
||||
status: 'incomplete',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
@@ -132,11 +154,14 @@ test('readSetupState migrates v1 state to v3 windows shortcut defaults', () => {
|
||||
desktopEnabled: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
bunInstallStatus: 'unknown',
|
||||
launcherInstallStatus: 'unknown',
|
||||
launcherInstallPath: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('readSetupState migrates completed v2 state to internal yomitan setup mode', () => {
|
||||
test('readSetupState migrates completed v2 state to v4 launcher defaults', () => {
|
||||
withTempDir((root) => {
|
||||
const statePath = getSetupStatePath(root);
|
||||
fs.writeFileSync(
|
||||
@@ -158,7 +183,7 @@ test('readSetupState migrates completed v2 state to internal yomitan setup mode'
|
||||
);
|
||||
|
||||
assert.deepEqual(readSetupState(statePath), {
|
||||
version: 3,
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-12T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
@@ -171,10 +196,80 @@ test('readSetupState migrates completed v2 state to internal yomitan setup mode'
|
||||
desktopEnabled: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
bunInstallStatus: 'unknown',
|
||||
launcherInstallStatus: 'unknown',
|
||||
launcherInstallPath: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('readSetupState migrates v3 state to v4 without requiring optional launcher fields', () => {
|
||||
withTempDir((root) => {
|
||||
const statePath = getSetupStatePath(root);
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
status: 'completed',
|
||||
completedAt: '2026-04-01T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'external',
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled: false,
|
||||
desktopEnabled: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'installed',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(readSetupState(statePath), {
|
||||
version: 4,
|
||||
status: 'completed',
|
||||
completedAt: '2026-04-01T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'external',
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled: false,
|
||||
desktopEnabled: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'installed',
|
||||
bunInstallStatus: 'unknown',
|
||||
launcherInstallStatus: 'unknown',
|
||||
launcherInstallPath: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeSetupState rejects invalid v4 Bun and launcher statuses', () => {
|
||||
const valid = {
|
||||
version: 4,
|
||||
status: 'incomplete',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
bunInstallStatus: 'unknown',
|
||||
launcherInstallStatus: 'unknown',
|
||||
launcherInstallPath: null,
|
||||
};
|
||||
|
||||
assert.equal(normalizeSetupState({ ...valid, bunInstallStatus: 'bogus' }), null);
|
||||
assert.equal(normalizeSetupState({ ...valid, launcherInstallStatus: 'bogus' }), null);
|
||||
});
|
||||
|
||||
test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults', () => {
|
||||
const linuxHomeDir = path.join(path.sep, 'tmp', 'home');
|
||||
const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
|
||||
|
||||
+54
-11
@@ -8,6 +8,8 @@ export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null;
|
||||
export type SetupYomitanMode = 'internal' | 'external' | null;
|
||||
export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
||||
export type SetupWindowsMpvShortcutInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
||||
export type SetupBunInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
||||
export type SetupLauncherInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
||||
|
||||
export interface SetupWindowsMpvShortcutPreferences {
|
||||
startMenuEnabled: boolean;
|
||||
@@ -15,7 +17,7 @@ export interface SetupWindowsMpvShortcutPreferences {
|
||||
}
|
||||
|
||||
export interface SetupState {
|
||||
version: 3;
|
||||
version: 4;
|
||||
status: SetupStateStatus;
|
||||
completedAt: string | null;
|
||||
completionSource: SetupCompletionSource;
|
||||
@@ -25,6 +27,9 @@ export interface SetupState {
|
||||
pluginInstallPathSummary: string | null;
|
||||
windowsMpvShortcutPreferences: SetupWindowsMpvShortcutPreferences;
|
||||
windowsMpvShortcutLastStatus: SetupWindowsMpvShortcutInstallStatus;
|
||||
bunInstallStatus: SetupBunInstallStatus;
|
||||
launcherInstallStatus: SetupLauncherInstallStatus;
|
||||
launcherInstallPath: string | null;
|
||||
}
|
||||
|
||||
export interface ConfigFilePaths {
|
||||
@@ -54,7 +59,7 @@ function asObject(value: unknown): Record<string, unknown> | null {
|
||||
|
||||
export function createDefaultSetupState(): SetupState {
|
||||
return {
|
||||
version: 3,
|
||||
version: 4,
|
||||
status: 'incomplete',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
@@ -67,6 +72,9 @@ export function createDefaultSetupState(): SetupState {
|
||||
desktopEnabled: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
bunInstallStatus: 'unknown',
|
||||
launcherInstallStatus: 'unknown',
|
||||
launcherInstallPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,9 +88,11 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
const yomitanSetupMode = record.yomitanSetupMode;
|
||||
const windowsPrefs = asObject(record.windowsMpvShortcutPreferences);
|
||||
const windowsMpvShortcutLastStatus = record.windowsMpvShortcutLastStatus;
|
||||
const bunInstallStatus = record.bunInstallStatus;
|
||||
const launcherInstallStatus = record.launcherInstallStatus;
|
||||
|
||||
if (
|
||||
(version !== 1 && version !== 2 && version !== 3) ||
|
||||
(version !== 1 && version !== 2 && version !== 3 && version !== 4) ||
|
||||
(status !== 'incomplete' &&
|
||||
status !== 'in_progress' &&
|
||||
status !== 'completed' &&
|
||||
@@ -91,7 +101,7 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
pluginInstallStatus !== 'installed' &&
|
||||
pluginInstallStatus !== 'skipped' &&
|
||||
pluginInstallStatus !== 'failed') ||
|
||||
(version === 2 &&
|
||||
((version === 2 || version === 3 || version === 4) &&
|
||||
windowsMpvShortcutLastStatus !== 'unknown' &&
|
||||
windowsMpvShortcutLastStatus !== 'installed' &&
|
||||
windowsMpvShortcutLastStatus !== 'skipped' &&
|
||||
@@ -99,21 +109,32 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
(completionSource !== null &&
|
||||
completionSource !== 'user' &&
|
||||
completionSource !== 'legacy_auto_detected') ||
|
||||
(version === 3 &&
|
||||
((version === 3 || version === 4) &&
|
||||
yomitanSetupMode !== null &&
|
||||
yomitanSetupMode !== 'internal' &&
|
||||
yomitanSetupMode !== 'external')
|
||||
yomitanSetupMode !== 'external') ||
|
||||
(version === 4 &&
|
||||
bunInstallStatus !== 'unknown' &&
|
||||
bunInstallStatus !== 'installed' &&
|
||||
bunInstallStatus !== 'skipped' &&
|
||||
bunInstallStatus !== 'failed') ||
|
||||
(version === 4 &&
|
||||
launcherInstallStatus !== 'unknown' &&
|
||||
launcherInstallStatus !== 'installed' &&
|
||||
launcherInstallStatus !== 'skipped' &&
|
||||
launcherInstallStatus !== 'failed')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: 3,
|
||||
version: 4,
|
||||
status,
|
||||
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
|
||||
completionSource,
|
||||
yomitanSetupMode:
|
||||
version === 3 && (yomitanSetupMode === 'internal' || yomitanSetupMode === 'external')
|
||||
(version === 3 || version === 4) &&
|
||||
(yomitanSetupMode === 'internal' || yomitanSetupMode === 'external')
|
||||
? yomitanSetupMode
|
||||
: status === 'completed'
|
||||
? 'internal'
|
||||
@@ -129,22 +150,44 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
typeof record.pluginInstallPathSummary === 'string' ? record.pluginInstallPathSummary : null,
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled:
|
||||
version === 2 && typeof windowsPrefs?.startMenuEnabled === 'boolean'
|
||||
(version === 2 || version === 3 || version === 4) &&
|
||||
typeof windowsPrefs?.startMenuEnabled === 'boolean'
|
||||
? windowsPrefs.startMenuEnabled
|
||||
: true,
|
||||
desktopEnabled:
|
||||
version === 2 && typeof windowsPrefs?.desktopEnabled === 'boolean'
|
||||
(version === 2 || version === 3 || version === 4) &&
|
||||
typeof windowsPrefs?.desktopEnabled === 'boolean'
|
||||
? windowsPrefs.desktopEnabled
|
||||
: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus:
|
||||
version === 2 &&
|
||||
(version === 2 || version === 3 || version === 4) &&
|
||||
(windowsMpvShortcutLastStatus === 'unknown' ||
|
||||
windowsMpvShortcutLastStatus === 'installed' ||
|
||||
windowsMpvShortcutLastStatus === 'skipped' ||
|
||||
windowsMpvShortcutLastStatus === 'failed')
|
||||
? windowsMpvShortcutLastStatus
|
||||
: 'unknown',
|
||||
bunInstallStatus:
|
||||
version === 4 &&
|
||||
(bunInstallStatus === 'unknown' ||
|
||||
bunInstallStatus === 'installed' ||
|
||||
bunInstallStatus === 'skipped' ||
|
||||
bunInstallStatus === 'failed')
|
||||
? bunInstallStatus
|
||||
: 'unknown',
|
||||
launcherInstallStatus:
|
||||
version === 4 &&
|
||||
(launcherInstallStatus === 'unknown' ||
|
||||
launcherInstallStatus === 'installed' ||
|
||||
launcherInstallStatus === 'skipped' ||
|
||||
launcherInstallStatus === 'failed')
|
||||
? launcherInstallStatus
|
||||
: 'unknown',
|
||||
launcherInstallPath:
|
||||
version === 4 && typeof record.launcherInstallPath === 'string'
|
||||
? record.launcherInstallPath
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,16 @@ export interface StartupWarmupsConfig {
|
||||
jellyfinRemoteSession?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateNotificationType = 'system' | 'osd' | 'both' | 'none';
|
||||
export type UpdateChannel = 'stable' | 'prerelease';
|
||||
|
||||
export interface UpdatesConfig {
|
||||
enabled?: boolean;
|
||||
checkIntervalHours?: number;
|
||||
notificationType?: UpdateNotificationType;
|
||||
channel?: UpdateChannel;
|
||||
}
|
||||
|
||||
export interface ShortcutsConfig {
|
||||
toggleVisibleOverlayGlobal?: string | null;
|
||||
copySubtitle?: string | null;
|
||||
@@ -122,6 +132,7 @@ export interface Config {
|
||||
youtubeSubgen?: YoutubeSubgenConfig;
|
||||
immersionTracking?: ImmersionTrackingConfig;
|
||||
stats?: StatsConfig;
|
||||
updates?: UpdatesConfig;
|
||||
logging?: {
|
||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||
};
|
||||
@@ -346,6 +357,7 @@ export interface ResolvedConfig {
|
||||
autoStartServer: boolean;
|
||||
autoOpenBrowser: boolean;
|
||||
};
|
||||
updates: Required<UpdatesConfig>;
|
||||
logging: {
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user