Prepare Windows release and signing process (#16)

This commit is contained in:
2026-03-08 19:51:30 -07:00
committed by GitHub
parent 34d2dce8dc
commit c799a8de3c
113 changed files with 5042 additions and 386 deletions

View File

@@ -29,12 +29,16 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'),
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
destroyFirstRunSetupWindow: () => calls.push('destroy-first-run-window'),
clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'),
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
cleanup();
assert.equal(calls.length, 22);
assert.equal(calls.length, 26);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));

View File

@@ -19,6 +19,10 @@ export function createOnWillQuitCleanupHandler(deps: {
clearAnilistSetupWindow: () => void;
destroyJellyfinSetupWindow: () => void;
clearJellyfinSetupWindow: () => void;
destroyFirstRunSetupWindow: () => void;
clearFirstRunSetupWindow: () => void;
destroyYomitanSettingsWindow: () => void;
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
stopDiscordPresenceService: () => void;
}) {
@@ -43,6 +47,10 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.clearAnilistSetupWindow();
deps.destroyJellyfinSetupWindow();
deps.clearJellyfinSetupWindow();
deps.destroyFirstRunSetupWindow();
deps.clearFirstRunSetupWindow();
deps.destroyYomitanSettingsWindow();
deps.clearYomitanSettingsWindow();
deps.stopJellyfinRemoteSession();
deps.stopDiscordPresenceService();
};

View File

@@ -46,6 +46,12 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }),
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
getFirstRunSetupWindow: () => ({ destroy: () => calls.push('destroy-first-run-window') }),
clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'),
getYomitanSettingsWindow: () => ({
destroy: () => calls.push('destroy-yomitan-settings-window'),
}),
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
@@ -61,6 +67,8 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('clear-reconnect-ref'));
assert.ok(calls.includes('destroy-immersion'));
assert.ok(calls.includes('clear-immersion-ref'));
assert.ok(calls.includes('destroy-first-run-window'));
assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('stop-discord-presence'));
assert.equal(reconnectTimer, null);
@@ -95,6 +103,10 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
getFirstRunSetupWindow: () => null,
clearFirstRunSetupWindow: () => {},
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
stopDiscordPresenceService: () => {},
});

View File

@@ -44,6 +44,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearAnilistSetupWindow: () => void;
getJellyfinSetupWindow: () => Destroyable | null;
clearJellyfinSetupWindow: () => void;
getFirstRunSetupWindow: () => Destroyable | null;
clearFirstRunSetupWindow: () => void;
getYomitanSettingsWindow: () => Destroyable | null;
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
stopDiscordPresenceService: () => void;
@@ -98,6 +102,14 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
deps.getJellyfinSetupWindow()?.destroy();
},
clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(),
destroyFirstRunSetupWindow: () => {
deps.getFirstRunSetupWindow()?.destroy();
},
clearFirstRunSetupWindow: () => deps.clearFirstRunSetupWindow(),
destroyYomitanSettingsWindow: () => {
deps.getYomitanSettingsWindow()?.destroy();
},
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
});

View File

@@ -35,6 +35,10 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
getFirstRunSetupWindow: () => null,
clearFirstRunSetupWindow: () => {},
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {},
stopDiscordPresenceService: () => {},
},

View File

@@ -9,6 +9,7 @@ import {
test('dictionary roots main handler returns expected root list', () => {
const roots = createBuildDictionaryRootsMainHandler({
platform: 'darwin',
dirname: '/repo/dist/main',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
@@ -44,6 +45,7 @@ test('jlpt dictionary runtime main deps builder maps search paths and log prefix
test('frequency dictionary roots main handler returns expected root list', () => {
const roots = createBuildFrequencyDictionaryRootsMainHandler({
platform: 'darwin',
dirname: '/repo/dist/main',
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
@@ -59,6 +61,42 @@ test('frequency dictionary roots main handler returns expected root list', () =>
assert.equal(roots[10], '/repo');
});
test('dictionary roots main handler uses APPDATA-style roots on windows', () => {
const roots = createBuildDictionaryRootsMainHandler({
platform: 'win32',
dirname: 'C:\\repo\\dist\\main',
appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar',
resourcesPath: 'C:\\Program Files\\SubMiner\\resources',
userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
homeDir: 'C:\\Users\\a',
appDataDir: 'C:\\Users\\a\\AppData\\Roaming',
cwd: 'C:\\repo',
joinPath: (...parts) => parts.join('\\'),
})();
assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false);
assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true);
});
test('frequency dictionary roots main handler uses APPDATA-style roots on windows', () => {
const roots = createBuildFrequencyDictionaryRootsMainHandler({
platform: 'win32',
dirname: 'C:\\repo\\dist\\main',
appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar',
resourcesPath: 'C:\\Program Files\\SubMiner\\resources',
userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
homeDir: 'C:\\Users\\a',
appDataDir: 'C:\\Users\\a\\AppData\\Roaming',
cwd: 'C:\\repo',
joinPath: (...parts) => parts.join('\\'),
})();
assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false);
assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true);
});
test('frequency dictionary runtime main deps builder maps search paths/source and log prefix', () => {
const calls: string[] = [];
const deps = createBuildFrequencyDictionaryRuntimeMainDepsHandler({

View File

@@ -3,53 +3,93 @@ import type { FrequencyDictionaryLookup, JlptLevel } from '../../types';
type JlptLookup = (term: string) => JlptLevel | null;
export function createBuildDictionaryRootsMainHandler(deps: {
platform: NodeJS.Platform;
dirname: string;
appPath: string;
resourcesPath: string;
userDataPath: string;
appUserDataPath: string;
homeDir: string;
appDataDir?: string;
cwd: string;
joinPath: (...parts: string[]) => string;
}) {
return () => [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'),
deps.userDataPath,
deps.appUserDataPath,
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
deps.cwd,
];
return () => {
const platformRoots =
deps.platform === 'win32'
? [
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'SubMiner',
),
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'subminer',
),
]
: [
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
];
return [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'),
deps.userDataPath,
deps.appUserDataPath,
...platformRoots,
deps.cwd,
];
};
}
export function createBuildFrequencyDictionaryRootsMainHandler(deps: {
platform: NodeJS.Platform;
dirname: string;
appPath: string;
resourcesPath: string;
userDataPath: string;
appUserDataPath: string;
homeDir: string;
appDataDir?: string;
cwd: string;
joinPath: (...parts: string[]) => string;
}) {
return () => [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'),
deps.userDataPath,
deps.appUserDataPath,
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
deps.cwd,
];
return () => {
const platformRoots =
deps.platform === 'win32'
? [
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'SubMiner',
),
deps.joinPath(
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
'subminer',
),
]
: [
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
deps.joinPath(deps.homeDir, '.config', 'subminer'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
];
return [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'frequency-dictionary'),
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'),
deps.userDataPath,
deps.appUserDataPath,
...platformRoots,
deps.cwd,
];
};
}
export function createBuildJlptDictionaryRuntimeMainDepsHandler(deps: {

View File

@@ -54,8 +54,10 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
fs.mkdirSync(path.dirname(installPaths.pluginEntrypointPath), { recursive: true });
fs.mkdirSync(installPaths.pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(path.join(installPaths.scriptsDir, 'subminer-loader.lua'), '-- old loader');
fs.writeFileSync(path.join(installPaths.pluginDir, 'old.lua'), '-- old plugin');
fs.writeFileSync(installPaths.pluginConfigPath, 'old=true\n');
@@ -72,7 +74,7 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(
fs.readFileSync(path.join(installPaths.pluginDir, 'main.lua'), 'utf8'),
fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'),
'-- packaged plugin',
);
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n');
@@ -83,6 +85,10 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
scriptsDirEntries.some((entry) => entry.startsWith('subminer.bak.')),
true,
);
assert.equal(
scriptsDirEntries.some((entry) => entry.startsWith('subminer-loader.lua.bak.')),
true,
);
assert.equal(
scriptOptsEntries.some((entry) => entry.startsWith('subminer.conf.bak.')),
true,
@@ -90,17 +96,71 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
});
});
test('installFirstRunPluginToDefaultLocation reports unsupported platforms', () => {
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir: '/tmp/home',
xdgConfigHome: '/tmp/xdg',
dirname: '/tmp/dist/main/runtime',
appPath: '/tmp/app',
resourcesPath: '/tmp/resources',
});
test('installFirstRunPluginToDefaultLocation installs plugin to Windows mpv defaults', () => {
if (process.platform !== 'win32') {
return;
}
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
assert.equal(result.ok, false);
assert.equal(result.pluginInstallStatus, 'failed');
assert.match(result.message, /not supported/i);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
});
assert.equal(result.ok, true);
assert.equal(result.pluginInstallStatus, 'installed');
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
assert.equal(
fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'),
'-- packaged plugin',
);
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'configured=true\n',
);
});
});
test('installFirstRunPluginToDefaultLocation rewrites Windows plugin socket_path', () => {
if (process.platform !== 'win32') {
return;
}
withTempDir((root) => {
const resourcesPath = path.join(root, 'resources');
const pluginRoot = path.join(resourcesPath, 'plugin');
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
fs.writeFileSync(
path.join(pluginRoot, 'subminer.conf'),
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
);
const result = installFirstRunPluginToDefaultLocation({
platform: 'win32',
homeDir,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
});
assert.equal(result.ok, true);
assert.equal(
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
'binary_path=\nsocket_path=\\\\.\\pipe\\subminer-socket\n',
);
});
});

View File

@@ -12,6 +12,25 @@ function backupExistingPath(targetPath: string): void {
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
}
function resolveLegacyPluginLoaderPath(installPaths: MpvInstallPaths): string {
return path.join(installPaths.scriptsDir, 'subminer.lua');
}
function resolveLegacyPluginDebugLoaderPath(installPaths: MpvInstallPaths): string {
return path.join(installPaths.scriptsDir, 'subminer-loader.lua');
}
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
const content = fs.readFileSync(configPath, 'utf8');
const updated = content.replace(
/^socket_path=.*$/m,
'socket_path=\\\\.\\pipe\\subminer-socket',
);
if (updated !== content) {
fs.writeFileSync(configPath, updated, 'utf8');
}
}
export function resolvePackagedFirstRunPluginAssets(deps: {
dirname: string;
appPath: string;
@@ -32,7 +51,11 @@ export function resolvePackagedFirstRunPluginAssets(deps: {
for (const root of roots) {
const pluginDirSource = joinPath(root, 'subminer');
const pluginConfigSource = joinPath(root, 'subminer.conf');
if (existsSync(pluginDirSource) && existsSync(pluginConfigSource)) {
if (
existsSync(pluginDirSource) &&
existsSync(pluginConfigSource) &&
existsSync(joinPath(pluginDirSource, 'main.lua'))
) {
return { pluginDirSource, pluginConfigSource };
}
}
@@ -45,7 +68,11 @@ export function detectInstalledFirstRunPlugin(
deps?: { existsSync?: (candidate: string) => boolean },
): boolean {
const existsSync = deps?.existsSync ?? fs.existsSync;
return existsSync(installPaths.pluginDir) && existsSync(installPaths.pluginConfigPath);
return (
existsSync(installPaths.pluginEntrypointPath) &&
existsSync(installPaths.pluginDir) &&
existsSync(installPaths.pluginConfigPath)
);
}
export function installFirstRunPluginToDefaultLocation(options: {
@@ -86,10 +113,15 @@ export function installFirstRunPluginToDefaultLocation(options: {
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
backupExistingPath(resolveLegacyPluginLoaderPath(installPaths));
backupExistingPath(resolveLegacyPluginDebugLoaderPath(installPaths));
backupExistingPath(installPaths.pluginDir);
backupExistingPath(installPaths.pluginConfigPath);
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
}
return {
ok: true,

View File

@@ -21,6 +21,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,
@@ -169,3 +171,79 @@ test('setup service marks cancelled when popup closes before completion', async
assert.equal(cancelled.state.status, 'cancelled');
});
});
test('setup service reflects detected Windows mpv shortcuts before preferences are persisted', 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({
platform: 'win32',
configDir,
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
detectWindowsMpvShortcuts: async () => ({
startMenuInstalled: false,
desktopInstalled: true,
}),
onStateChanged: () => undefined,
});
const snapshot = await service.ensureSetupStateInitialized();
assert.equal(snapshot.windowsMpvShortcuts.startMenuEnabled, false);
assert.equal(snapshot.windowsMpvShortcuts.desktopEnabled, true);
assert.equal(snapshot.windowsMpvShortcuts.startMenuInstalled, false);
assert.equal(snapshot.windowsMpvShortcuts.desktopInstalled, true);
});
});
test('setup service persists Windows mpv shortcut preferences and status with one state write', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const stateChanges: string[] = [];
const service = createFirstRunSetupService({
platform: 'win32',
configDir,
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => false,
installPlugin: async () => ({
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
message: 'ok',
}),
applyWindowsMpvShortcuts: async () => ({
ok: true,
status: 'installed',
message: 'shortcuts updated',
}),
onStateChanged: (state) => {
stateChanges.push(state.windowsMpvShortcutLastStatus);
},
});
await service.ensureSetupStateInitialized();
stateChanges.length = 0;
const snapshot = await service.configureWindowsMpvShortcuts({
startMenuEnabled: false,
desktopEnabled: true,
});
assert.equal(snapshot.windowsMpvShortcuts.startMenuEnabled, false);
assert.equal(snapshot.windowsMpvShortcuts.desktopEnabled, true);
assert.equal(snapshot.state.windowsMpvShortcutLastStatus, 'installed');
assert.equal(snapshot.message, 'shortcuts updated');
assert.deepEqual(stateChanges, ['installed']);
});
});

View File

@@ -7,16 +7,28 @@ import {
readSetupState,
writeSetupState,
type SetupPluginInstallStatus,
type SetupWindowsMpvShortcutInstallStatus,
type SetupState,
} from '../../shared/setup-state';
import type { CliArgs } from '../../cli/args';
export interface SetupWindowsMpvShortcutSnapshot {
supported: boolean;
startMenuEnabled: boolean;
desktopEnabled: boolean;
startMenuInstalled: boolean;
desktopInstalled: boolean;
status: 'installed' | 'optional' | 'skipped' | 'failed';
message: string | null;
}
export interface SetupStatusSnapshot {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
message: string | null;
state: SetupState;
}
@@ -37,6 +49,10 @@ export interface FirstRunSetupService {
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
skipPluginInstall: () => Promise<SetupStatusSnapshot>;
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
configureWindowsMpvShortcuts: (preferences: {
startMenuEnabled: boolean;
desktopEnabled: boolean;
}) => Promise<SetupStatusSnapshot>;
isSetupCompleted: () => boolean;
}
@@ -44,6 +60,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
return Boolean(
args.toggle ||
args.toggleVisibleOverlay ||
args.launchMpv ||
args.settings ||
args.show ||
args.hide ||
@@ -95,15 +112,51 @@ function getPluginStatus(
return 'optional';
}
function getWindowsMpvShortcutStatus(
state: SetupState,
installed: { startMenuInstalled: boolean; desktopInstalled: boolean },
): SetupWindowsMpvShortcutSnapshot['status'] {
if (installed.startMenuInstalled || installed.desktopInstalled) return 'installed';
if (state.windowsMpvShortcutLastStatus === 'skipped') return 'skipped';
if (state.windowsMpvShortcutLastStatus === 'failed') return 'failed';
return 'optional';
}
function getEffectiveWindowsMpvShortcutPreferences(
state: SetupState,
installed: { startMenuInstalled: boolean; desktopInstalled: boolean },
): { startMenuEnabled: boolean; desktopEnabled: boolean } {
if (state.windowsMpvShortcutLastStatus === 'unknown') {
return {
startMenuEnabled: installed.startMenuInstalled,
desktopEnabled: installed.desktopInstalled,
};
}
return {
startMenuEnabled: state.windowsMpvShortcutPreferences.startMenuEnabled,
desktopEnabled: state.windowsMpvShortcutPreferences.desktopEnabled,
};
}
export function createFirstRunSetupService(deps: {
platform?: NodeJS.Platform;
configDir: string;
getYomitanDictionaryCount: () => Promise<number>;
detectPluginInstalled: () => boolean | Promise<boolean>;
installPlugin: () => Promise<PluginInstallResult>;
detectWindowsMpvShortcuts?: () =>
| { startMenuInstalled: boolean; desktopInstalled: boolean }
| Promise<{ startMenuInstalled: boolean; desktopInstalled: boolean }>;
applyWindowsMpvShortcuts?: (preferences: {
startMenuEnabled: boolean;
desktopEnabled: boolean;
}) => Promise<{ ok: boolean; status: SetupWindowsMpvShortcutInstallStatus; message: string }>;
onStateChanged?: (state: SetupState) => void;
}): FirstRunSetupService {
const setupStatePath = getSetupStatePath(deps.configDir);
const configFilePaths = getDefaultConfigFilePaths(deps.configDir);
const isWindows = (deps.platform ?? process.platform) === 'win32';
let completed = false;
const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState();
@@ -117,6 +170,17 @@ export function createFirstRunSetupService(deps: {
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
const dictionaryCount = await deps.getYomitanDictionaryCount();
const pluginInstalled = await deps.detectPluginInstalled();
const detectedWindowsMpvShortcuts = isWindows
? await deps.detectWindowsMpvShortcuts?.()
: undefined;
const installedWindowsMpvShortcuts = {
startMenuInstalled: detectedWindowsMpvShortcuts?.startMenuInstalled ?? false,
desktopInstalled: detectedWindowsMpvShortcuts?.desktopInstalled ?? false,
};
const effectiveWindowsMpvShortcutPreferences = getEffectiveWindowsMpvShortcutPreferences(
state,
installedWindowsMpvShortcuts,
);
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
return {
@@ -125,6 +189,15 @@ export function createFirstRunSetupService(deps: {
canFinish: dictionaryCount >= 1,
pluginStatus: getPluginStatus(state, pluginInstalled),
pluginInstallPathSummary: state.pluginInstallPathSummary,
windowsMpvShortcuts: {
supported: isWindows,
startMenuEnabled: effectiveWindowsMpvShortcutPreferences.startMenuEnabled,
desktopEnabled: effectiveWindowsMpvShortcutPreferences.desktopEnabled,
startMenuInstalled: installedWindowsMpvShortcuts.startMenuInstalled,
desktopInstalled: installedWindowsMpvShortcuts.desktopInstalled,
status: getWindowsMpvShortcutStatus(state, installedWindowsMpvShortcuts),
message: null,
},
message,
state,
} satisfies SetupStatusSnapshot;
@@ -220,6 +293,33 @@ export function createFirstRunSetupService(deps: {
result.message,
);
},
configureWindowsMpvShortcuts: async (preferences) => {
if (!isWindows || !deps.applyWindowsMpvShortcuts) {
return refreshWithState(
writeState({
...readState(),
windowsMpvShortcutPreferences: {
startMenuEnabled: preferences.startMenuEnabled,
desktopEnabled: preferences.desktopEnabled,
},
}),
null,
);
}
const result = await deps.applyWindowsMpvShortcuts(preferences);
const latestState = readState();
return refreshWithState(
writeState({
...latestState,
windowsMpvShortcutPreferences: {
startMenuEnabled: preferences.startMenuEnabled,
desktopEnabled: preferences.desktopEnabled,
},
windowsMpvShortcutLastStatus: result.status,
}),
result.message,
);
},
isSetupCompleted: () => completed || isSetupCompleted(readState()),
};
}

View File

@@ -4,6 +4,7 @@ import {
buildFirstRunSetupHtml,
createHandleFirstRunSetupNavigationHandler,
createMaybeFocusExistingFirstRunSetupWindowHandler,
createOpenFirstRunSetupWindowHandler,
parseFirstRunSetupSubmissionUrl,
} from './first-run-setup-window';
@@ -14,6 +15,14 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
canFinish: false,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: 'Waiting for dictionaries',
});
@@ -31,6 +40,14 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
canFinish: true,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcuts: {
supported: true,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: true,
desktopInstalled: false,
status: 'installed',
},
message: null,
});
@@ -60,8 +77,8 @@ test('first-run setup navigation handler prevents default and dispatches action'
const calls: string[] = [];
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
handleAction: async (action) => {
calls.push(action);
handleAction: async (submission) => {
calls.push(submission.action);
},
logError: (message) => calls.push(message),
});
@@ -75,3 +92,71 @@ test('first-run setup navigation handler prevents default and dispatches action'
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
});
test('closing incomplete first-run setup quits app outside background mode', async () => {
const calls: string[] = [];
let closedHandler: (() => void) | undefined;
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: () => {},
},
loadURL: async () => undefined,
on: (event: 'closed', callback: () => void) => {
if (event === 'closed') {
closedHandler = callback;
}
},
isDestroyed: () => false,
close: () => calls.push('close-window'),
focus: () => {},
}) as never,
getSetupSnapshot: async () => ({
configReady: false,
dictionaryCount: 0,
canFinish: false,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: null,
}),
buildSetupHtml: () => '<html></html>',
parseSubmissionUrl: () => null,
handleAction: async () => undefined,
markSetupInProgress: async () => undefined,
markSetupCancelled: async () => {
calls.push('cancelled');
},
isSetupCompleted: () => false,
shouldQuitWhenClosedIncomplete: () => true,
quitApp: () => {
calls.push('quit');
},
clearSetupWindow: () => {
calls.push('clear');
},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
if (typeof closedHandler !== 'function') {
throw new Error('expected closed handler');
}
closedHandler();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['set', 'cancelled', 'clear', 'quit']);
});

View File

@@ -16,17 +16,32 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
export type FirstRunSetupAction =
| 'install-plugin'
| 'configure-windows-mpv-shortcuts'
| 'open-yomitan-settings'
| 'refresh'
| 'skip-plugin'
| 'finish';
export interface FirstRunSetupSubmission {
action: FirstRunSetupAction;
startMenuEnabled?: boolean;
desktopEnabled?: boolean;
}
export interface FirstRunSetupHtmlModel {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
windowsMpvShortcuts: {
supported: boolean;
startMenuEnabled: boolean;
desktopEnabled: boolean;
startMenuInstalled: boolean;
desktopInstalled: boolean;
status: 'installed' | 'optional' | 'skipped' | 'failed';
};
message: string | null;
}
@@ -61,6 +76,43 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.pluginStatus === 'skipped'
? 'muted'
: 'warn';
const windowsShortcutLabel =
model.windowsMpvShortcuts.status === 'installed'
? 'Installed'
: model.windowsMpvShortcuts.status === 'skipped'
? 'Skipped'
: model.windowsMpvShortcuts.status === 'failed'
? 'Failed'
: 'Optional';
const windowsShortcutTone =
model.windowsMpvShortcuts.status === 'installed'
? 'ready'
: model.windowsMpvShortcuts.status === 'failed'
? 'danger'
: model.windowsMpvShortcuts.status === 'skipped'
? 'muted'
: 'warn';
const windowsShortcutCard = model.windowsMpvShortcuts.supported
? `
<div class="card block">
<div class="card-head">
<div>
<strong>Windows mpv launcher</strong>
<div class="meta">Create standalone \`SubMiner mpv\` shortcuts that run \`SubMiner.exe --launch-mpv\`.</div>
<div class="meta">Installed: Start Menu ${model.windowsMpvShortcuts.startMenuInstalled ? 'yes' : 'no'}, Desktop ${model.windowsMpvShortcuts.desktopInstalled ? 'yes' : 'no'}</div>
</div>
${renderStatusBadge(windowsShortcutLabel, windowsShortcutTone)}
</div>
<form
class="shortcut-form"
onsubmit="event.preventDefault(); const params = new URLSearchParams({ action: 'configure-windows-mpv-shortcuts', startMenu: document.getElementById('shortcut-start-menu').checked ? '1' : '0', desktop: document.getElementById('shortcut-desktop').checked ? '1' : '0' }); window.location.href = 'subminer://first-run-setup?' + params.toString();"
>
<label><input id="shortcut-start-menu" type="checkbox" ${model.windowsMpvShortcuts.startMenuEnabled ? 'checked' : ''} /> Create Start Menu shortcut</label>
<label><input id="shortcut-desktop" type="checkbox" ${model.windowsMpvShortcuts.desktopEnabled ? 'checked' : ''} /> Create Desktop shortcut</label>
<button type="submit">Apply mpv launcher shortcuts</button>
</form>
</div>`
: '';
return `<!doctype html>
<html>
@@ -109,10 +161,30 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
align-items: center;
gap: 12px;
}
.card.block {
display: block;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.meta {
color: var(--muted);
font-size: 12px;
}
.shortcut-form {
display: grid;
gap: 8px;
margin-top: 12px;
}
label {
color: var(--muted);
display: flex;
align-items: center;
gap: 8px;
}
.badge {
display: inline-flex;
align-items: center;
@@ -192,6 +264,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
model.dictionaryCount >= 1 ? 'ready' : 'warn',
)}
</div>
${windowsShortcutCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
@@ -208,7 +281,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
export function parseFirstRunSetupSubmissionUrl(
rawUrl: string,
): { action: FirstRunSetupAction } | null {
): FirstRunSetupSubmission | null {
if (!rawUrl.startsWith('subminer://first-run-setup')) {
return null;
}
@@ -216,6 +289,7 @@ export function parseFirstRunSetupSubmissionUrl(
const action = parsed.searchParams.get('action');
if (
action !== 'install-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
action !== 'open-yomitan-settings' &&
action !== 'refresh' &&
action !== 'skip-plugin' &&
@@ -223,6 +297,13 @@ export function parseFirstRunSetupSubmissionUrl(
) {
return null;
}
if (action === 'configure-windows-mpv-shortcuts') {
return {
action,
startMenuEnabled: parsed.searchParams.get('startMenu') === '1',
desktopEnabled: parsed.searchParams.get('desktop') === '1',
};
}
return { action };
}
@@ -238,15 +319,15 @@ export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
}
export function createHandleFirstRunSetupNavigationHandler(deps: {
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
handleAction: (action: FirstRunSetupAction) => Promise<unknown>;
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
handleAction: (submission: FirstRunSetupSubmission) => Promise<unknown>;
logError: (message: string, error: unknown) => void;
}) {
return (params: { url: string; preventDefault: () => void }): boolean => {
const submission = deps.parseSubmissionUrl(params.url);
if (!submission) return false;
params.preventDefault();
void deps.handleAction(submission.action).catch((error) => {
void deps.handleAction(submission).catch((error) => {
deps.logError('Failed handling first-run setup action', error);
});
return true;
@@ -260,11 +341,13 @@ export function createOpenFirstRunSetupWindowHandler<
createSetupWindow: () => TWindow;
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
handleAction: (action: FirstRunSetupAction) => Promise<{ closeWindow?: boolean } | void>;
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
handleAction: (submission: FirstRunSetupSubmission) => Promise<{ closeWindow?: boolean } | void>;
markSetupInProgress: () => Promise<unknown>;
markSetupCancelled: () => Promise<unknown>;
isSetupCompleted: () => boolean;
shouldQuitWhenClosedIncomplete: () => boolean;
quitApp: () => void;
clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void;
encodeURIComponent: (value: string) => string;
@@ -286,8 +369,8 @@ export function createOpenFirstRunSetupWindowHandler<
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: deps.parseSubmissionUrl,
handleAction: async (action) => {
const result = await deps.handleAction(action);
handleAction: async (submission) => {
const result = await deps.handleAction(submission);
if (result?.closeWindow) {
if (!setupWindow.isDestroyed()) {
setupWindow.close();
@@ -313,12 +396,16 @@ export function createOpenFirstRunSetupWindowHandler<
});
setupWindow.on('closed', () => {
if (!deps.isSetupCompleted()) {
const setupCompleted = deps.isSetupCompleted();
if (!setupCompleted) {
void deps.markSetupCancelled().catch((error) => {
deps.logError('Failed marking first-run setup cancelled', error);
});
}
deps.clearSetupWindow();
if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) {
deps.quitApp();
}
});
void deps

View File

@@ -7,6 +7,7 @@ test('initial args handler no-ops without initial args', () => {
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => null,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
@@ -26,6 +27,7 @@ test('initial args handler ensures tray in background mode', () => {
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => {
ensuredTray = true;
},
@@ -46,6 +48,7 @@ test('initial args handler auto-connects mpv when needed', () => {
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
@@ -71,6 +74,7 @@ test('initial args handler forwards args to cli handler', () => {
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => {},
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => false,
@@ -84,3 +88,23 @@ test('initial args handler forwards args to cli handler', () => {
handleInitialArgs();
assert.deepEqual(seenSources, ['initial']);
});
test('initial args handler can ensure tray outside background mode when requested', () => {
let ensuredTray = false;
const handleInitialArgs = createHandleInitialArgsHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => false,
shouldEnsureTrayOnStartup: () => true,
ensureTray: () => {
ensuredTray = true;
},
isTexthookerOnlyMode: () => true,
hasImmersionTracker: () => false,
getMpvClient: () => null,
logInfo: () => {},
handleCliCommand: () => {},
});
handleInitialArgs();
assert.equal(ensuredTray, true);
});

View File

@@ -8,6 +8,7 @@ type MpvClientLike = {
export function createHandleInitialArgsHandler(deps: {
getInitialArgs: () => CliArgs | null;
isBackgroundMode: () => boolean;
shouldEnsureTrayOnStartup: () => boolean;
ensureTray: () => void;
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
@@ -19,7 +20,7 @@ export function createHandleInitialArgsHandler(deps: {
const initialArgs = deps.getInitialArgs();
if (!initialArgs) return;
if (deps.isBackgroundMode()) {
if (deps.isBackgroundMode() || deps.shouldEnsureTrayOnStartup()) {
deps.ensureTray();
}

View File

@@ -9,6 +9,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
const deps = createBuildHandleInitialArgsMainDepsHandler({
getInitialArgs: () => args,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => calls.push('ensure-tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,
@@ -19,6 +20,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
assert.equal(deps.getInitialArgs(), args);
assert.equal(deps.isBackgroundMode(), true);
assert.equal(deps.shouldEnsureTrayOnStartup(), false);
assert.equal(deps.isTexthookerOnlyMode(), false);
assert.equal(deps.hasImmersionTracker(), true);
assert.equal(deps.getMpvClient(), mpvClient);

View File

@@ -3,6 +3,7 @@ import type { CliArgs } from '../../cli/args';
export function createBuildHandleInitialArgsMainDepsHandler(deps: {
getInitialArgs: () => CliArgs | null;
isBackgroundMode: () => boolean;
shouldEnsureTrayOnStartup: () => boolean;
ensureTray: () => void;
isTexthookerOnlyMode: () => boolean;
hasImmersionTracker: () => boolean;
@@ -13,6 +14,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
return () => ({
getInitialArgs: () => deps.getInitialArgs(),
isBackgroundMode: () => deps.isBackgroundMode(),
shouldEnsureTrayOnStartup: () => deps.shouldEnsureTrayOnStartup(),
ensureTray: () => deps.ensureTray(),
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
hasImmersionTracker: () => deps.hasImmersionTracker(),

View File

@@ -7,6 +7,7 @@ test('initial args runtime handler composes main deps and runs initial command f
const handleInitialArgs = createInitialArgsRuntimeHandler({
getInitialArgs: () => ({ start: true }) as never,
isBackgroundMode: () => true,
shouldEnsureTrayOnStartup: () => false,
ensureTray: () => calls.push('tray'),
isTexthookerOnlyMode: () => false,
hasImmersionTracker: () => true,

View File

@@ -48,6 +48,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync-immersion'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
@@ -82,6 +83,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.maybeProbeAnilistDuration('media-key');
deps.ensureAnilistMediaGuess('media-key');
deps.syncImmersionMediaState();
deps.signalAutoplayReadyIfWarm('/tmp/video');
deps.updateCurrentMediaTitle('title');
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate('title');
@@ -100,6 +102,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('ensure-immersion'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('autoplay:/tmp/video'));
assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));

View File

@@ -13,8 +13,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
calls.push(`registered:${registered}`);
},
isOverlayRuntimeInitialized: () => true,
isMacOSPlatform: () => true,
isTrackedMpvWindowFocused: () => false,
isOverlayShortcutContextActive: () => false,
showMpvOsd: (text) => calls.push(`osd:${text}`),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJimaku: () => calls.push('jimaku'),
@@ -42,8 +41,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
})();
assert.equal(deps.isOverlayRuntimeInitialized(), true);
assert.equal(deps.isMacOSPlatform(), true);
assert.equal(deps.isTrackedMpvWindowFocused(), false);
assert.equal(deps.isOverlayShortcutContextActive?.(), false);
assert.equal(deps.getShortcutsRegistered(), false);
deps.setShortcutsRegistered(true);
assert.equal(shortcutsRegistered, true);

View File

@@ -8,8 +8,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
getShortcutsRegistered: () => deps.getShortcutsRegistered(),
setShortcutsRegistered: (registered: boolean) => deps.setShortcutsRegistered(registered),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
isMacOSPlatform: () => deps.isMacOSPlatform(),
isTrackedMpvWindowFocused: () => deps.isTrackedMpvWindowFocused(),
isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true,
showMpvOsd: (text: string) => deps.showMpvOsd(text),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openJimaku: () => deps.openJimaku(),

View File

@@ -25,6 +25,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
isMacOSPlatform: () => true,
isWindowsPlatform: () => false,
showOverlayLoadingOsd: () => calls.push('overlay-loading-osd'),
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 20, height: 20 }),
})();
@@ -39,6 +40,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
deps.enforceOverlayLayerOrder();
deps.syncOverlayShortcuts();
assert.equal(deps.isMacOSPlatform(), true);
assert.equal(deps.isWindowsPlatform(), false);
deps.showOverlayLoadingOsd('Overlay loading...');
assert.deepEqual(deps.resolveFallbackBounds(), { x: 0, y: 0, width: 20, height: 20 });
assert.equal(trackerNotReadyWarningShown, true);

View File

@@ -18,6 +18,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
isMacOSPlatform: () => deps.isMacOSPlatform(),
isWindowsPlatform: () => deps.isWindowsPlatform(),
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
});

View File

@@ -43,6 +43,7 @@ test('build tray template handler wires actions and init guards', () => {
buildTrayMenuTemplateRuntime: (handlers) => {
handlers.openOverlay();
handlers.openFirstRunSetup();
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
handlers.openRuntimeOptions();
handlers.openJellyfinSetup();
@@ -58,6 +59,7 @@ test('build tray template handler wires actions and init guards', () => {
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
@@ -71,6 +73,7 @@ test('build tray template handler wires actions and init guards', () => {
'init',
'visible:true',
'setup',
'setup',
'yomitan',
'runtime-options',
'jellyfin',

View File

@@ -31,6 +31,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -42,6 +44,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
setVisibleOverlayVisible: (visible: boolean) => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
@@ -60,6 +63,10 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
deps.openFirstRunSetupWindow();
},
showFirstRunSetup: deps.showFirstRunSetup(),
openWindowsMpvLauncherSetup: () => {
deps.openFirstRunSetupWindow();
},
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
openYomitanSettings: () => {
deps.openYomitanSettings();
},

View File

@@ -27,6 +27,7 @@ test('tray main deps builders return mapped handlers', () => {
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
@@ -38,6 +39,8 @@ test('tray main deps builders return mapped handlers', () => {
openOverlay: () => calls.push('open-overlay'),
openFirstRunSetup: () => calls.push('open-setup'),
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('open-yomitan'),
openRuntimeOptions: () => calls.push('open-runtime-options'),
openJellyfinSetup: () => calls.push('open-jellyfin'),

View File

@@ -30,6 +30,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -41,6 +43,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
setVisibleOverlayVisible: (visible: boolean) => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
@@ -54,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
showFirstRunSetup: deps.showFirstRunSetup,
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,

View File

@@ -29,6 +29,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
},
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => {},
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {},
openRuntimeOptionsPalette: () => {},
openJellyfinSetupWindow: () => {},

View File

@@ -32,6 +32,8 @@ test('tray menu template contains expected entries and handlers', () => {
openOverlay: () => calls.push('overlay'),
openFirstRunSetup: () => calls.push('setup'),
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('yomitan'),
openRuntimeOptions: () => calls.push('runtime'),
openJellyfinSetup: () => calls.push('jellyfin'),
@@ -39,10 +41,10 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 8);
assert.equal(template.length, 9);
template[0]!.click?.();
template[6]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[7]!.click?.();
template[7]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[8]!.click?.();
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
});
@@ -51,6 +53,8 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
openOverlay: () => undefined,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openJellyfinSetup: () => undefined,
@@ -61,4 +65,5 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
.filter(Boolean);
assert.equal(labels.includes('Complete Setup'), false);
assert.equal(labels.includes('Manage Windows mpv launcher'), false);
});

View File

@@ -33,6 +33,8 @@ export type TrayMenuActionHandlers = {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -58,6 +60,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
},
]
: []),
...(handlers.showWindowsMpvLauncherSetup
? [
{
label: 'Manage Windows mpv launcher',
click: handlers.openWindowsMpvLauncherSetup,
},
]
: []),
{
label: 'Open Yomitan Settings',
click: handlers.openYomitanSettings,

View File

@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildWindowsMpvLaunchArgs,
launchWindowsMpv,
resolveWindowsMpvPath,
type WindowsMpvLaunchDeps,
} from './windows-mpv-launch';
function createDeps(overrides: Partial<WindowsMpvLaunchDeps> = {}): WindowsMpvLaunchDeps {
return {
getEnv: () => undefined,
runWhere: () => ({ status: 1, stdout: '' }),
fileExists: () => false,
spawnDetached: () => undefined,
showError: () => undefined,
...overrides,
};
}
test('resolveWindowsMpvPath prefers SUBMINER_MPV_PATH', () => {
const resolved = resolveWindowsMpvPath(
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
}),
);
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
});
test('resolveWindowsMpvPath falls back to where.exe output', () => {
const resolved = resolveWindowsMpvPath(
createDeps({
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\nC:\\other\\mpv.exe\r\n' }),
fileExists: (candidate) => candidate === 'C:\\tools\\mpv.exe',
}),
);
assert.equal(resolved, 'C:\\tools\\mpv.exe');
});
test('buildWindowsMpvLaunchArgs keeps pseudo-gui profile and targets', () => {
assert.deepEqual(buildWindowsMpvLaunchArgs(['C:\\a.mkv', 'C:\\b.mkv']), [
'--player-operation-mode=pseudo-gui',
'--profile=subminer',
'C:\\a.mkv',
'C:\\b.mkv',
]);
});
test('launchWindowsMpv reports missing mpv path', () => {
const errors: string[] = [];
const result = launchWindowsMpv(
[],
createDeps({
showError: (_title, content) => errors.push(content),
}),
);
assert.equal(result.ok, false);
assert.equal(result.mpvPath, '');
assert.match(errors[0] ?? '', /Could not find mpv\.exe/i);
});
test('launchWindowsMpv spawns detached mpv with targets', () => {
const calls: string[] = [];
const result = launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: (command, args) => {
calls.push(command);
calls.push(args.join('|'));
},
}),
);
assert.equal(result.ok, true);
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
assert.deepEqual(calls, [
'C:\\mpv\\mpv.exe',
'--player-operation-mode=pseudo-gui|--profile=subminer|C:\\video.mkv',
]);
});
test('launchWindowsMpv reports spawn failures with path context', () => {
const errors: string[] = [];
const result = launchWindowsMpv(
[],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: () => {
throw new Error('spawn failed');
},
showError: (_title, content) => errors.push(content),
}),
);
assert.equal(result.ok, false);
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
assert.match(errors[0] ?? '', /Failed to launch mpv/i);
assert.match(errors[0] ?? '', /C:\\mpv\\mpv\.exe/i);
});

View File

@@ -0,0 +1,100 @@
import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
spawnDetached: (command: string, args: string[]) => void;
showError: (title: string, content: string) => void;
}
function normalizeCandidate(candidate: string | undefined): string {
return typeof candidate === 'string' ? candidate.trim() : '';
}
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
const envPath = normalizeCandidate(deps.getEnv('SUBMINER_MPV_PATH'));
if (envPath && deps.fileExists(envPath)) {
return envPath;
}
const whereResult = deps.runWhere();
if (whereResult.status === 0) {
const firstPath = whereResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0 && deps.fileExists(line));
if (firstPath) {
return firstPath;
}
}
return '';
}
export function buildWindowsMpvLaunchArgs(targets: string[]): string[] {
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...targets];
}
export function launchWindowsMpv(
targets: string[],
deps: WindowsMpvLaunchDeps,
): { ok: boolean; mpvPath: string } {
const mpvPath = resolveWindowsMpvPath(deps);
if (!mpvPath) {
deps.showError(
'SubMiner mpv launcher',
'Could not find mpv.exe. Install mpv and add it to PATH, or set SUBMINER_MPV_PATH.',
);
return { ok: false, mpvPath: '' };
}
try {
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets));
return { ok: true, mpvPath };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.showError('SubMiner mpv launcher', `Failed to launch mpv.\nPath: ${mpvPath}\n${message}`);
return { ok: false, mpvPath };
}
}
export function createWindowsMpvLaunchDeps(options: {
getEnv?: (name: string) => string | undefined;
fileExists?: (candidate: string) => boolean;
showError: (title: string, content: string) => void;
}): WindowsMpvLaunchDeps {
return {
getEnv: options.getEnv ?? ((name) => process.env[name]),
runWhere: () => {
const result = spawnSync('where.exe', ['mpv.exe'], {
encoding: 'utf8',
windowsHide: true,
});
return {
status: result.status,
stdout: result.stdout ?? '',
error: result.error ?? undefined,
};
},
fileExists:
options.fileExists ??
((candidate) => {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}),
spawnDetached: (command, args) => {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
},
showError: options.showError,
};
}

View File

@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyWindowsMpvShortcuts,
buildWindowsMpvShortcutDetails,
detectWindowsMpvShortcuts,
resolveWindowsMpvShortcutPaths,
resolveWindowsStartMenuProgramsDir,
} from './windows-mpv-shortcuts';
test('resolveWindowsStartMenuProgramsDir derives Programs folder from APPDATA', () => {
assert.equal(
resolveWindowsStartMenuProgramsDir('C:\\Users\\tester\\AppData\\Roaming'),
'C:\\Users\\tester\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs',
);
});
test('resolveWindowsMpvShortcutPaths builds start menu and desktop lnk paths', () => {
const paths = resolveWindowsMpvShortcutPaths({
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
desktopDir: 'C:\\Users\\tester\\Desktop',
});
assert.equal(
paths.startMenuPath,
'C:\\Users\\tester\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\SubMiner mpv.lnk',
);
assert.equal(paths.desktopPath, 'C:\\Users\\tester\\Desktop\\SubMiner mpv.lnk');
});
test('buildWindowsMpvShortcutDetails targets SubMiner.exe with --launch-mpv', () => {
assert.deepEqual(buildWindowsMpvShortcutDetails('C:\\Apps\\SubMiner\\SubMiner.exe'), {
target: 'C:\\Apps\\SubMiner\\SubMiner.exe',
args: '--launch-mpv',
cwd: 'C:\\Apps\\SubMiner',
description: 'Launch mpv with the SubMiner profile',
icon: 'C:\\Apps\\SubMiner\\SubMiner.exe',
iconIndex: 0,
});
});
test('detectWindowsMpvShortcuts reflects existing shortcuts', () => {
const detected = detectWindowsMpvShortcuts(
{
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
},
(candidate) => candidate === 'C:\\Desktop\\SubMiner mpv.lnk',
);
assert.deepEqual(detected, {
startMenuInstalled: false,
desktopInstalled: true,
});
});
test('applyWindowsMpvShortcuts creates enabled shortcuts and removes disabled ones', () => {
const writes: string[] = [];
const removes: string[] = [];
const result = applyWindowsMpvShortcuts({
preferences: {
startMenuEnabled: true,
desktopEnabled: false,
},
paths: {
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
},
exePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
writeShortcutLink: (shortcutPath, operation, details) => {
writes.push(`${shortcutPath}|${operation}|${details.target}|${details.args}`);
return true;
},
rmSync: (candidate) => {
removes.push(candidate);
},
mkdirSync: () => undefined,
});
assert.equal(result.ok, true);
assert.equal(result.status, 'installed');
assert.deepEqual(writes, [
'C:\\Programs\\SubMiner mpv.lnk|replace|C:\\Apps\\SubMiner\\SubMiner.exe|--launch-mpv',
]);
assert.deepEqual(removes, ['C:\\Desktop\\SubMiner mpv.lnk']);
});
test('applyWindowsMpvShortcuts returns skipped when both shortcuts are disabled', () => {
const removes: string[] = [];
const result = applyWindowsMpvShortcuts({
preferences: {
startMenuEnabled: false,
desktopEnabled: false,
},
paths: {
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
},
exePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
writeShortcutLink: () => true,
rmSync: (candidate) => {
removes.push(candidate);
},
mkdirSync: () => undefined,
});
assert.equal(result.ok, true);
assert.equal(result.status, 'skipped');
assert.deepEqual(removes, ['C:\\Programs\\SubMiner mpv.lnk', 'C:\\Desktop\\SubMiner mpv.lnk']);
});
test('applyWindowsMpvShortcuts reports write failures', () => {
const result = applyWindowsMpvShortcuts({
preferences: {
startMenuEnabled: true,
desktopEnabled: true,
},
paths: {
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
},
exePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
writeShortcutLink: (shortcutPath) => shortcutPath.endsWith('Desktop\\SubMiner mpv.lnk'),
mkdirSync: () => undefined,
});
assert.equal(result.ok, false);
assert.equal(result.status, 'failed');
assert.match(result.message, /C:\\Programs\\SubMiner mpv\.lnk/);
});

View File

@@ -0,0 +1,117 @@
import fs from 'node:fs';
import path from 'node:path';
export const WINDOWS_MPV_SHORTCUT_NAME = 'SubMiner mpv.lnk';
export interface WindowsMpvShortcutPaths {
startMenuPath: string;
desktopPath: string;
}
export interface WindowsShortcutLinkDetails {
target: string;
args?: string;
cwd?: string;
description?: string;
icon?: string;
iconIndex?: number;
}
export interface WindowsMpvShortcutInstallResult {
ok: boolean;
status: 'installed' | 'skipped' | 'failed';
message: string;
}
export function resolveWindowsStartMenuProgramsDir(appDataDir: string): string {
return path.join(appDataDir, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
}
export function resolveWindowsMpvShortcutPaths(options: {
appDataDir: string;
desktopDir: string;
}): WindowsMpvShortcutPaths {
return {
startMenuPath: path.join(resolveWindowsStartMenuProgramsDir(options.appDataDir), WINDOWS_MPV_SHORTCUT_NAME),
desktopPath: path.join(options.desktopDir, WINDOWS_MPV_SHORTCUT_NAME),
};
}
export function detectWindowsMpvShortcuts(
paths: WindowsMpvShortcutPaths,
existsSync: (candidate: string) => boolean = fs.existsSync,
): { startMenuInstalled: boolean; desktopInstalled: boolean } {
return {
startMenuInstalled: existsSync(paths.startMenuPath),
desktopInstalled: existsSync(paths.desktopPath),
};
}
export function buildWindowsMpvShortcutDetails(exePath: string): WindowsShortcutLinkDetails {
return {
target: exePath,
args: '--launch-mpv',
cwd: path.dirname(exePath),
description: 'Launch mpv with the SubMiner profile',
icon: exePath,
iconIndex: 0,
};
}
export function applyWindowsMpvShortcuts(options: {
preferences: { startMenuEnabled: boolean; desktopEnabled: boolean };
paths: WindowsMpvShortcutPaths;
exePath: string;
writeShortcutLink: (
shortcutPath: string,
operation: 'create' | 'update' | 'replace',
details: WindowsShortcutLinkDetails,
) => boolean;
rmSync?: (candidate: string, options: { force: true }) => void;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
}): WindowsMpvShortcutInstallResult {
const rmSync = options.rmSync ?? fs.rmSync;
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
const details = buildWindowsMpvShortcutDetails(options.exePath);
const failures: string[] = [];
const ensureShortcut = (shortcutPath: string): void => {
mkdirSync(path.dirname(shortcutPath), { recursive: true });
const ok = options.writeShortcutLink(shortcutPath, 'replace', details);
if (!ok) {
failures.push(shortcutPath);
}
};
const removeShortcut = (shortcutPath: string): void => {
rmSync(shortcutPath, { force: true });
};
if (options.preferences.startMenuEnabled) ensureShortcut(options.paths.startMenuPath);
else removeShortcut(options.paths.startMenuPath);
if (options.preferences.desktopEnabled) ensureShortcut(options.paths.desktopPath);
else removeShortcut(options.paths.desktopPath);
if (failures.length > 0) {
return {
ok: false,
status: 'failed',
message: `Failed to create Windows mpv shortcuts: ${failures.join(', ')}`,
};
}
if (!options.preferences.startMenuEnabled && !options.preferences.desktopEnabled) {
return {
ok: true,
status: 'skipped',
message: 'Disabled Windows mpv shortcuts.',
};
}
return {
ok: true,
status: 'installed',
message: 'Updated Windows mpv shortcuts.',
};
}

View File

@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { AnkiConnectConfig } from '../../types';
import {
getPreferredYomitanAnkiServerUrl,
shouldForceOverrideYomitanAnkiServer,
} from './yomitan-anki-server';
function createConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
return {
enabled: false,
url: 'http://127.0.0.1:8765',
proxy: {
enabled: true,
host: '127.0.0.1',
port: 8766,
upstreamUrl: 'http://127.0.0.1:8765',
},
...overrides,
} as AnkiConnectConfig;
}
test('prefers upstream AnkiConnect when SubMiner integration is disabled', () => {
const config = createConfig({
enabled: false,
proxy: {
enabled: true,
host: '127.0.0.1',
port: 8766,
upstreamUrl: 'http://127.0.0.1:8765',
},
});
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:8765');
assert.equal(shouldForceOverrideYomitanAnkiServer(config), false);
});
test('prefers SubMiner proxy when SubMiner integration and proxy are enabled', () => {
const config = createConfig({
enabled: true,
proxy: {
enabled: true,
host: '127.0.0.1',
port: 9988,
upstreamUrl: 'http://127.0.0.1:8765',
},
});
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:9988');
assert.equal(shouldForceOverrideYomitanAnkiServer(config), true);
});
test('falls back to upstream AnkiConnect when proxy transport is disabled', () => {
const config = createConfig({
enabled: true,
proxy: {
enabled: false,
host: '127.0.0.1',
port: 8766,
upstreamUrl: 'http://127.0.0.1:8765',
},
});
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:8765');
assert.equal(shouldForceOverrideYomitanAnkiServer(config), false);
});

View File

@@ -0,0 +1,15 @@
import type { AnkiConnectConfig } from '../../types';
export function getPreferredYomitanAnkiServerUrl(config: AnkiConnectConfig): string {
if (config.enabled === true && config.proxy?.enabled === true) {
const host = config.proxy.host || '127.0.0.1';
const port = config.proxy.port || 8766;
return `http://${host}:${port}`;
}
return config.url || 'http://127.0.0.1:8765';
}
export function shouldForceOverrideYomitanAnkiServer(config: AnkiConnectConfig): boolean {
return config.enabled === true && config.proxy?.enabled === true;
}