mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Prepare Windows release and signing process (#16)
This commit is contained in:
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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: () => {},
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -35,6 +35,10 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
clearAnilistSetupWindow: () => {},
|
||||
getJellyfinSetupWindow: () => null,
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
getFirstRunSetupWindow: () => null,
|
||||
clearFirstRunSetupWindow: () => {},
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,6 +29,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
},
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => {},
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
106
src/main/runtime/windows-mpv-launch.test.ts
Normal file
106
src/main/runtime/windows-mpv-launch.test.ts
Normal 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);
|
||||
});
|
||||
100
src/main/runtime/windows-mpv-launch.ts
Normal file
100
src/main/runtime/windows-mpv-launch.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
130
src/main/runtime/windows-mpv-shortcuts.test.ts
Normal file
130
src/main/runtime/windows-mpv-shortcuts.test.ts
Normal 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/);
|
||||
});
|
||||
117
src/main/runtime/windows-mpv-shortcuts.ts
Normal file
117
src/main/runtime/windows-mpv-shortcuts.ts
Normal 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.',
|
||||
};
|
||||
}
|
||||
66
src/main/runtime/yomitan-anki-server.test.ts
Normal file
66
src/main/runtime/yomitan-anki-server.test.ts
Normal 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);
|
||||
});
|
||||
15
src/main/runtime/yomitan-anki-server.ts
Normal file
15
src/main/runtime/yomitan-anki-server.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user