@@ -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
;
+ parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
+ handleAction: (submission: FirstRunSetupSubmission) => Promise;
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;
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;
markSetupCancelled: () => Promise;
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
diff --git a/src/main/runtime/initial-args-handler.test.ts b/src/main/runtime/initial-args-handler.test.ts
index ef836c0..3a72302 100644
--- a/src/main/runtime/initial-args-handler.test.ts
+++ b/src/main/runtime/initial-args-handler.test.ts
@@ -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);
+});
diff --git a/src/main/runtime/initial-args-handler.ts b/src/main/runtime/initial-args-handler.ts
index 2dcc02e..dac3ae1 100644
--- a/src/main/runtime/initial-args-handler.ts
+++ b/src/main/runtime/initial-args-handler.ts
@@ -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();
}
diff --git a/src/main/runtime/initial-args-main-deps.test.ts b/src/main/runtime/initial-args-main-deps.test.ts
index efd3fc3..ab7d6c9 100644
--- a/src/main/runtime/initial-args-main-deps.test.ts
+++ b/src/main/runtime/initial-args-main-deps.test.ts
@@ -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);
diff --git a/src/main/runtime/initial-args-main-deps.ts b/src/main/runtime/initial-args-main-deps.ts
index f0b7a64..96670c9 100644
--- a/src/main/runtime/initial-args-main-deps.ts
+++ b/src/main/runtime/initial-args-main-deps.ts
@@ -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(),
diff --git a/src/main/runtime/initial-args-runtime-handler.test.ts b/src/main/runtime/initial-args-runtime-handler.test.ts
index 44d9242..86f77fc 100644
--- a/src/main/runtime/initial-args-runtime-handler.test.ts
+++ b/src/main/runtime/initial-args-runtime-handler.test.ts
@@ -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,
diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts
index 71e3a10..0ed1108 100644
--- a/src/main/runtime/mpv-main-event-main-deps.test.ts
+++ b/src/main/runtime/mpv-main-event-main-deps.test.ts
@@ -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'));
diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts
index 1287271..69c1126 100644
--- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts
+++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts
@@ -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);
diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts
index 5915fe4..b6eb9b1 100644
--- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts
+++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts
@@ -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(),
diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts
index ba4a823..9ee9680 100644
--- a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts
+++ b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts
@@ -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);
diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts
index 72c7024..78c4039 100644
--- a/src/main/runtime/overlay-visibility-runtime-main-deps.ts
+++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts
@@ -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(),
});
diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts
index 6293d69..bffc375 100644
--- a/src/main/runtime/tray-main-actions.test.ts
+++ b/src/main/runtime/tray-main-actions.test.ts
@@ -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',
diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts
index c3b6f0e..c38bf23 100644
--- a/src/main/runtime/tray-main-actions.ts
+++ b/src/main/runtime/tray-main-actions.ts
@@ -31,6 +31,8 @@ export function createBuildTrayMenuTemplateHandler(deps: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
+ openWindowsMpvLauncherSetup: () => void;
+ showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -42,6 +44,7 @@ export function createBuildTrayMenuTemplateHandler(deps: {
setVisibleOverlayVisible: (visible: boolean) => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
+ showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
@@ -60,6 +63,10 @@ export function createBuildTrayMenuTemplateHandler(deps: {
deps.openFirstRunSetupWindow();
},
showFirstRunSetup: deps.showFirstRunSetup(),
+ openWindowsMpvLauncherSetup: () => {
+ deps.openFirstRunSetupWindow();
+ },
+ showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
openYomitanSettings: () => {
deps.openYomitanSettings();
},
diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts
index 644c358..d33ab8c 100644
--- a/src/main/runtime/tray-main-deps.test.ts
+++ b/src/main/runtime/tray-main-deps.test.ts
@@ -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'),
diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts
index 3e37a6b..57e601b 100644
--- a/src/main/runtime/tray-main-deps.ts
+++ b/src/main/runtime/tray-main-deps.ts
@@ -30,6 +30,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
+ openWindowsMpvLauncherSetup: () => void;
+ showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -41,6 +43,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: {
setVisibleOverlayVisible: (visible: boolean) => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
+ showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
@@ -54,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: {
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
showFirstRunSetup: deps.showFirstRunSetup,
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
+ showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts
index 7d8df2b..16c1cc3 100644
--- a/src/main/runtime/tray-runtime-handlers.test.ts
+++ b/src/main/runtime/tray-runtime-handlers.test.ts
@@ -29,6 +29,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
},
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => {},
+ showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {},
openRuntimeOptionsPalette: () => {},
openJellyfinSetupWindow: () => {},
diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts
index 4ff92e9..f25ab59 100644
--- a/src/main/runtime/tray-runtime.test.ts
+++ b/src/main/runtime/tray-runtime.test.ts
@@ -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);
});
diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts
index 5d7ad41..f6b3ec8 100644
--- a/src/main/runtime/tray-runtime.ts
+++ b/src/main/runtime/tray-runtime.ts
@@ -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,
diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts
new file mode 100644
index 0000000..e45c806
--- /dev/null
+++ b/src/main/runtime/windows-mpv-launch.test.ts
@@ -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 {
+ 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);
+});
diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts
new file mode 100644
index 0000000..8e3555a
--- /dev/null
+++ b/src/main/runtime/windows-mpv-launch.ts
@@ -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,
+ };
+}
diff --git a/src/main/runtime/windows-mpv-shortcuts.test.ts b/src/main/runtime/windows-mpv-shortcuts.test.ts
new file mode 100644
index 0000000..a0c864f
--- /dev/null
+++ b/src/main/runtime/windows-mpv-shortcuts.test.ts
@@ -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/);
+});
diff --git a/src/main/runtime/windows-mpv-shortcuts.ts b/src/main/runtime/windows-mpv-shortcuts.ts
new file mode 100644
index 0000000..4dc349b
--- /dev/null
+++ b/src/main/runtime/windows-mpv-shortcuts.ts
@@ -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.',
+ };
+}
diff --git a/src/main/runtime/yomitan-anki-server.test.ts b/src/main/runtime/yomitan-anki-server.test.ts
new file mode 100644
index 0000000..9aa37b5
--- /dev/null
+++ b/src/main/runtime/yomitan-anki-server.test.ts
@@ -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 {
+ 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);
+});
diff --git a/src/main/runtime/yomitan-anki-server.ts b/src/main/runtime/yomitan-anki-server.ts
new file mode 100644
index 0000000..36ce6ec
--- /dev/null
+++ b/src/main/runtime/yomitan-anki-server.ts
@@ -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;
+}
diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts
index 4073974..9916c5a 100644
--- a/src/release-workflow.test.ts
+++ b/src/release-workflow.test.ts
@@ -5,6 +5,8 @@ import { resolve } from 'node:path';
const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml');
const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8');
+const makefilePath = resolve(__dirname, '../Makefile');
+const makefile = readFileSync(makefilePath, 'utf8');
test('publish release leaves prerelease unset so gh creates a normal release', () => {
assert.ok(!releaseWorkflow.includes('--prerelease'));
@@ -18,3 +20,13 @@ test('release workflow generates release notes from committed changelog output',
assert.match(releaseWorkflow, /bun run changelog:release-notes/);
assert.ok(!releaseWorkflow.includes('git log --pretty=format:"- %s"'));
});
+
+test('release workflow includes the Windows installer in checksums and uploaded assets', () => {
+ assert.match(releaseWorkflow, /files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/);
+ assert.match(releaseWorkflow, /artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/);
+});
+
+test('Makefile routes Windows install-plugin setup through bun and documents Windows builds', () => {
+ assert.match(makefile, /windows\) printf '%s\\n' "\[INFO\] Windows builds run via: bun run build:win" ;;/);
+ assert.match(makefile, /bun \.\/scripts\/configure-plugin-binary-path\.mjs/);
+});
diff --git a/src/renderer/positioning/position-state.test.ts b/src/renderer/positioning/position-state.test.ts
new file mode 100644
index 0000000..c8c9f10
--- /dev/null
+++ b/src/renderer/positioning/position-state.test.ts
@@ -0,0 +1,83 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import { createInMemorySubtitlePositionController } from './position-state.js';
+
+function withWindow(windowValue: unknown, callback: () => T): T {
+ const previousWindow = (globalThis as { window?: unknown }).window;
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: windowValue,
+ });
+
+ try {
+ return callback();
+ } finally {
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: previousWindow,
+ });
+ }
+}
+
+function createContext(subtitleHeight: number) {
+ return {
+ dom: {
+ subtitleContainer: {
+ style: {
+ position: '',
+ left: '',
+ top: '',
+ right: '',
+ transform: '',
+ marginBottom: '',
+ },
+ offsetHeight: subtitleHeight,
+ },
+ },
+ state: {
+ currentYPercent: null,
+ persistedSubtitlePosition: { yPercent: 10 },
+ },
+ };
+}
+
+test('subtitle position clamp keeps tall subtitles inside the overlay viewport', () => {
+ withWindow(
+ {
+ innerHeight: 1000,
+ electronAPI: {
+ saveSubtitlePosition: () => {},
+ },
+ },
+ () => {
+ const ctx = createContext(300);
+ const controller = createInMemorySubtitlePositionController(ctx as never);
+
+ controller.applyYPercent(80);
+
+ assert.equal(ctx.state.currentYPercent, 68.8);
+ assert.equal(ctx.dom.subtitleContainer.style.marginBottom, '688px');
+ },
+ );
+});
+
+test('subtitle position clamp falls back to the minimum safe inset when subtitle is taller than the viewport', () => {
+ withWindow(
+ {
+ innerHeight: 200,
+ electronAPI: {
+ saveSubtitlePosition: () => {},
+ },
+ },
+ () => {
+ const ctx = createContext(260);
+ const controller = createInMemorySubtitlePositionController(ctx as never);
+
+ controller.applyYPercent(80);
+
+ assert.equal(ctx.state.currentYPercent, 6);
+ assert.equal(ctx.dom.subtitleContainer.style.marginBottom, '12px');
+ },
+ );
+});
diff --git a/src/renderer/positioning/position-state.ts b/src/renderer/positioning/position-state.ts
index c21ce8d..ac88d54 100644
--- a/src/renderer/positioning/position-state.ts
+++ b/src/renderer/positioning/position-state.ts
@@ -3,6 +3,7 @@ import type { RendererContext } from '../context';
const PREFERRED_Y_PERCENT_MIN = 2;
const PREFERRED_Y_PERCENT_MAX = 80;
+const SUBTITLE_EDGE_PADDING_PX = 12;
export type SubtitlePositionController = {
applyStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void;
@@ -11,8 +12,47 @@ export type SubtitlePositionController = {
persistSubtitlePositionPatch: (patch: Partial) => void;
};
-function clampYPercent(yPercent: number): number {
- return Math.max(PREFERRED_Y_PERCENT_MIN, Math.min(PREFERRED_Y_PERCENT_MAX, yPercent));
+function getViewportHeight(): number {
+ return Math.max(window.innerHeight || 0, 1);
+}
+
+function getSubtitleContainerHeight(ctx: RendererContext): number {
+ const container = ctx.dom.subtitleContainer as HTMLElement & {
+ offsetHeight?: number;
+ getBoundingClientRect?: () => { height?: number };
+ };
+ if (typeof container.offsetHeight === 'number' && Number.isFinite(container.offsetHeight)) {
+ return Math.max(container.offsetHeight, 0);
+ }
+ if (typeof container.getBoundingClientRect === 'function') {
+ const height = container.getBoundingClientRect().height;
+ if (typeof height === 'number' && Number.isFinite(height)) {
+ return Math.max(height, 0);
+ }
+ }
+ return 0;
+}
+
+function resolveYPercentClampRange(ctx: RendererContext): { min: number; max: number } {
+ const viewportHeight = getViewportHeight();
+ const subtitleHeight = getSubtitleContainerHeight(ctx);
+ const minPercent = Math.max(PREFERRED_Y_PERCENT_MIN, (SUBTITLE_EDGE_PADDING_PX / viewportHeight) * 100);
+ const maxMarginBottomPx = Math.max(
+ SUBTITLE_EDGE_PADDING_PX,
+ viewportHeight - subtitleHeight - SUBTITLE_EDGE_PADDING_PX,
+ );
+ const maxPercent = Math.min(PREFERRED_Y_PERCENT_MAX, (maxMarginBottomPx / viewportHeight) * 100);
+
+ if (maxPercent < minPercent) {
+ return { min: minPercent, max: minPercent };
+ }
+
+ return { min: minPercent, max: maxPercent };
+}
+
+function clampYPercent(ctx: RendererContext, yPercent: number): number {
+ const { min, max } = resolveYPercentClampRange(ctx);
+ return Math.max(min, Math.min(max, yPercent));
}
function getPersistedYPercent(ctx: RendererContext, position: SubtitlePosition | null): number {
@@ -53,14 +93,14 @@ export function createInMemorySubtitlePositionController(
}
const marginBottom = parseFloat(ctx.dom.subtitleContainer.style.marginBottom) || 60;
- ctx.state.currentYPercent = clampYPercent((marginBottom / window.innerHeight) * 100);
+ ctx.state.currentYPercent = clampYPercent(ctx, (marginBottom / getViewportHeight()) * 100);
return ctx.state.currentYPercent;
}
function applyYPercent(yPercent: number): void {
- const clampedPercent = clampYPercent(yPercent);
+ const clampedPercent = clampYPercent(ctx, yPercent);
ctx.state.currentYPercent = clampedPercent;
- const marginBottom = (clampedPercent / 100) * window.innerHeight;
+ const marginBottom = (clampedPercent / 100) * getViewportHeight();
ctx.dom.subtitleContainer.style.position = '';
ctx.dom.subtitleContainer.style.left = '';
@@ -85,7 +125,7 @@ export function createInMemorySubtitlePositionController(
}
const defaultMarginBottom = 60;
- const defaultYPercent = (defaultMarginBottom / window.innerHeight) * 100;
+ const defaultYPercent = (defaultMarginBottom / getViewportHeight()) * 100;
applyYPercent(defaultYPercent);
console.log('Applied default subtitle position from', source);
}
diff --git a/src/shared/setup-state.test.ts b/src/shared/setup-state.test.ts
index 4f409b8..97627f8 100644
--- a/src/shared/setup-state.test.ts
+++ b/src/shared/setup-state.test.ts
@@ -24,13 +24,17 @@ function withTempDir(fn: (dir: string) => void): void {
}
test('getDefaultConfigDir prefers existing SubMiner config directory', () => {
+ const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
+ const homeDir = path.join(path.sep, 'tmp', 'home');
const dir = getDefaultConfigDir({
- xdgConfigHome: '/tmp/xdg',
- homeDir: '/tmp/home',
- existsSync: (candidate) => candidate === '/tmp/xdg/SubMiner/config.jsonc',
+ platform: 'linux',
+ xdgConfigHome,
+ homeDir,
+ existsSync: (candidate) =>
+ candidate === path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
});
- assert.equal(dir, '/tmp/xdg/SubMiner');
+ assert.equal(dir, path.posix.join(xdgConfigHome, 'SubMiner'));
});
test('ensureDefaultConfigBootstrap creates config dir and default jsonc only when missing', () => {
@@ -61,6 +65,26 @@ test('ensureDefaultConfigBootstrap creates config dir and default jsonc only whe
});
});
+test('ensureDefaultConfigBootstrap does not seed default config into an existing config directory', () => {
+ withTempDir((root) => {
+ const configDir = path.join(root, 'SubMiner');
+ fs.mkdirSync(configDir, { recursive: true });
+ fs.writeFileSync(path.join(configDir, 'existing-user-file.txt'), 'keep\n');
+
+ ensureDefaultConfigBootstrap({
+ configDir,
+ configFilePaths: getDefaultConfigFilePaths(configDir),
+ generateTemplate: () => 'should-not-write',
+ });
+
+ assert.equal(fs.existsSync(path.join(configDir, 'config.jsonc')), false);
+ assert.equal(
+ fs.readFileSync(path.join(configDir, 'existing-user-file.txt'), 'utf8'),
+ 'keep\n',
+ );
+ });
+});
+
test('readSetupState ignores invalid files and round-trips valid state', () => {
withTempDir((root) => {
const statePath = getSetupStatePath(root);
@@ -77,22 +101,126 @@ test('readSetupState ignores invalid files and round-trips valid state', () => {
});
});
-test('resolveDefaultMpvInstallPaths resolves linux and macOS defaults', () => {
- assert.deepEqual(resolveDefaultMpvInstallPaths('linux', '/tmp/home', '/tmp/xdg'), {
- supported: true,
- mpvConfigDir: '/tmp/xdg/mpv',
- scriptsDir: '/tmp/xdg/mpv/scripts',
- scriptOptsDir: '/tmp/xdg/mpv/script-opts',
- pluginDir: '/tmp/xdg/mpv/scripts/subminer',
- pluginConfigPath: '/tmp/xdg/mpv/script-opts/subminer.conf',
- });
+test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => {
+ withTempDir((root) => {
+ const statePath = getSetupStatePath(root);
+ fs.writeFileSync(
+ statePath,
+ JSON.stringify({
+ version: 1,
+ status: 'incomplete',
+ completedAt: null,
+ completionSource: null,
+ lastSeenYomitanDictionaryCount: 0,
+ pluginInstallStatus: 'unknown',
+ pluginInstallPathSummary: null,
+ }),
+ );
- assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', '/Users/tester', undefined), {
- supported: true,
- mpvConfigDir: '/Users/tester/Library/Application Support/mpv',
- scriptsDir: '/Users/tester/Library/Application Support/mpv/scripts',
- scriptOptsDir: '/Users/tester/Library/Application Support/mpv/script-opts',
- pluginDir: '/Users/tester/Library/Application Support/mpv/scripts/subminer',
- pluginConfigPath: '/Users/tester/Library/Application Support/mpv/script-opts/subminer.conf',
+ assert.deepEqual(readSetupState(statePath), {
+ version: 2,
+ status: 'incomplete',
+ completedAt: null,
+ completionSource: null,
+ lastSeenYomitanDictionaryCount: 0,
+ pluginInstallStatus: 'unknown',
+ pluginInstallPathSummary: null,
+ windowsMpvShortcutPreferences: {
+ startMenuEnabled: true,
+ desktopEnabled: true,
+ },
+ windowsMpvShortcutLastStatus: 'unknown',
+ });
+ });
+});
+
+test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults', () => {
+ const linuxHomeDir = path.join(path.sep, 'tmp', 'home');
+ const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
+ assert.deepEqual(resolveDefaultMpvInstallPaths('linux', linuxHomeDir, xdgConfigHome), {
+ supported: true,
+ mpvConfigDir: path.posix.join(xdgConfigHome, 'mpv'),
+ scriptsDir: path.posix.join(xdgConfigHome, 'mpv', 'scripts'),
+ scriptOptsDir: path.posix.join(xdgConfigHome, 'mpv', 'script-opts'),
+ pluginEntrypointPath: path.posix.join(xdgConfigHome, 'mpv', 'scripts', 'subminer', 'main.lua'),
+ pluginDir: path.posix.join(xdgConfigHome, 'mpv', 'scripts', 'subminer'),
+ pluginConfigPath: path.posix.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
+ });
+
+ const macHomeDir = path.join(path.sep, 'Users', 'tester');
+ assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', macHomeDir, undefined), {
+ supported: true,
+ mpvConfigDir: path.posix.join(macHomeDir, 'Library', 'Application Support', 'mpv'),
+ scriptsDir: path.posix.join(macHomeDir, 'Library', 'Application Support', 'mpv', 'scripts'),
+ scriptOptsDir: path.posix.join(
+ macHomeDir,
+ 'Library',
+ 'Application Support',
+ 'mpv',
+ 'script-opts',
+ ),
+ pluginEntrypointPath: path.posix.join(
+ macHomeDir,
+ 'Library',
+ 'Application Support',
+ 'mpv',
+ 'scripts',
+ 'subminer',
+ 'main.lua',
+ ),
+ pluginDir: path.posix.join(
+ macHomeDir,
+ 'Library',
+ 'Application Support',
+ 'mpv',
+ 'scripts',
+ 'subminer',
+ ),
+ pluginConfigPath: path.posix.join(
+ macHomeDir,
+ 'Library',
+ 'Application Support',
+ 'mpv',
+ 'script-opts',
+ 'subminer.conf',
+ ),
+ });
+
+ assert.deepEqual(resolveDefaultMpvInstallPaths('win32', 'C:\\Users\\tester', undefined), {
+ supported: true,
+ mpvConfigDir: path.win32.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv'),
+ scriptsDir: path.win32.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv', 'scripts'),
+ scriptOptsDir: path.win32.join(
+ 'C:\\Users\\tester',
+ 'AppData',
+ 'Roaming',
+ 'mpv',
+ 'script-opts',
+ ),
+ pluginEntrypointPath: path.win32.join(
+ 'C:\\Users\\tester',
+ 'AppData',
+ 'Roaming',
+ 'mpv',
+ 'scripts',
+ 'subminer',
+ 'main.lua',
+ ),
+ pluginDir: path.win32.join(
+ 'C:\\Users\\tester',
+ 'AppData',
+ 'Roaming',
+ 'mpv',
+ 'scripts',
+ 'subminer',
+ ),
+ pluginConfigPath: path.win32.join(
+ 'C:\\Users\\tester',
+ 'AppData',
+ 'Roaming',
+ 'mpv',
+ 'script-opts',
+ 'subminer.conf',
+ ),
});
});
diff --git a/src/shared/setup-state.ts b/src/shared/setup-state.ts
index ab9bc2b..c82cc99 100644
--- a/src/shared/setup-state.ts
+++ b/src/shared/setup-state.ts
@@ -6,15 +6,23 @@ import { resolveConfigDir } from '../config/path-resolution';
export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled';
export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null;
export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
+export type SetupWindowsMpvShortcutInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
+
+export interface SetupWindowsMpvShortcutPreferences {
+ startMenuEnabled: boolean;
+ desktopEnabled: boolean;
+}
export interface SetupState {
- version: 1;
+ version: 2;
status: SetupStateStatus;
completedAt: string | null;
completionSource: SetupCompletionSource;
lastSeenYomitanDictionaryCount: number;
pluginInstallStatus: SetupPluginInstallStatus;
pluginInstallPathSummary: string | null;
+ windowsMpvShortcutPreferences: SetupWindowsMpvShortcutPreferences;
+ windowsMpvShortcutLastStatus: SetupWindowsMpvShortcutInstallStatus;
}
export interface ConfigFilePaths {
@@ -27,10 +35,15 @@ export interface MpvInstallPaths {
mpvConfigDir: string;
scriptsDir: string;
scriptOptsDir: string;
+ pluginEntrypointPath: string;
pluginDir: string;
pluginConfigPath: string;
}
+function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
+ return platform === 'win32' ? path.win32 : path.posix;
+}
+
function asObject(value: unknown): Record | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record)
@@ -39,25 +52,33 @@ function asObject(value: unknown): Record | null {
export function createDefaultSetupState(): SetupState {
return {
- version: 1,
+ version: 2,
status: 'incomplete',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
+ windowsMpvShortcutPreferences: {
+ startMenuEnabled: true,
+ desktopEnabled: true,
+ },
+ windowsMpvShortcutLastStatus: 'unknown',
};
}
export function normalizeSetupState(value: unknown): SetupState | null {
const record = asObject(value);
if (!record) return null;
+ const version = record.version;
const status = record.status;
const pluginInstallStatus = record.pluginInstallStatus;
const completionSource = record.completionSource;
+ const windowsPrefs = asObject(record.windowsMpvShortcutPreferences);
+ const windowsMpvShortcutLastStatus = record.windowsMpvShortcutLastStatus;
if (
- record.version !== 1 ||
+ (version !== 1 && version !== 2) ||
(status !== 'incomplete' &&
status !== 'in_progress' &&
status !== 'completed' &&
@@ -66,6 +87,11 @@ export function normalizeSetupState(value: unknown): SetupState | null {
pluginInstallStatus !== 'installed' &&
pluginInstallStatus !== 'skipped' &&
pluginInstallStatus !== 'failed') ||
+ (version === 2 &&
+ windowsMpvShortcutLastStatus !== 'unknown' &&
+ windowsMpvShortcutLastStatus !== 'installed' &&
+ windowsMpvShortcutLastStatus !== 'skipped' &&
+ windowsMpvShortcutLastStatus !== 'failed') ||
(completionSource !== null &&
completionSource !== 'user' &&
completionSource !== 'legacy_auto_detected')
@@ -74,7 +100,7 @@ export function normalizeSetupState(value: unknown): SetupState | null {
}
return {
- version: 1,
+ version: 2,
status,
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
completionSource,
@@ -87,6 +113,24 @@ export function normalizeSetupState(value: unknown): SetupState | null {
pluginInstallStatus,
pluginInstallPathSummary:
typeof record.pluginInstallPathSummary === 'string' ? record.pluginInstallPathSummary : null,
+ windowsMpvShortcutPreferences: {
+ startMenuEnabled:
+ version === 2 && typeof windowsPrefs?.startMenuEnabled === 'boolean'
+ ? windowsPrefs.startMenuEnabled
+ : true,
+ desktopEnabled:
+ version === 2 && typeof windowsPrefs?.desktopEnabled === 'boolean'
+ ? windowsPrefs.desktopEnabled
+ : true,
+ },
+ windowsMpvShortcutLastStatus:
+ version === 2 &&
+ (windowsMpvShortcutLastStatus === 'unknown' ||
+ windowsMpvShortcutLastStatus === 'installed' ||
+ windowsMpvShortcutLastStatus === 'skipped' ||
+ windowsMpvShortcutLastStatus === 'failed')
+ ? windowsMpvShortcutLastStatus
+ : 'unknown',
};
}
@@ -95,11 +139,15 @@ export function isSetupCompleted(state: SetupState | null | undefined): boolean
}
export function getDefaultConfigDir(options?: {
+ platform?: NodeJS.Platform;
+ appDataDir?: string;
xdgConfigHome?: string;
homeDir?: string;
existsSync?: (candidate: string) => boolean;
}): string {
return resolveConfigDir({
+ platform: options?.platform ?? process.platform,
+ appDataDir: options?.appDataDir ?? process.env.APPDATA,
xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
homeDir: options?.homeDir ?? os.homedir(),
existsSync: options?.existsSync ?? fs.existsSync,
@@ -160,15 +208,17 @@ export function ensureDefaultConfigBootstrap(options: {
const existsSync = options.existsSync ?? fs.existsSync;
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
+ const configDirExists = existsSync(options.configDir);
- mkdirSync(options.configDir, { recursive: true });
if (
existsSync(options.configFilePaths.jsoncPath) ||
- existsSync(options.configFilePaths.jsonPath)
+ existsSync(options.configFilePaths.jsonPath) ||
+ configDirExists
) {
return;
}
+ mkdirSync(options.configDir, { recursive: true });
writeFileSync(options.configFilePaths.jsoncPath, options.generateTemplate(), 'utf8');
}
@@ -177,19 +227,21 @@ export function resolveDefaultMpvInstallPaths(
homeDir: string,
xdgConfigHome?: string,
): MpvInstallPaths {
+ const platformPath = getPlatformPath(platform);
const mpvConfigDir =
platform === 'darwin'
- ? path.join(homeDir, 'Library', 'Application Support', 'mpv')
+ ? platformPath.join(homeDir, 'Library', 'Application Support', 'mpv')
: platform === 'linux'
- ? path.join(xdgConfigHome?.trim() || path.join(homeDir, '.config'), 'mpv')
- : path.join(homeDir, 'AppData', 'Roaming', 'mpv');
+ ? platformPath.join(xdgConfigHome?.trim() || platformPath.join(homeDir, '.config'), 'mpv')
+ : platformPath.join(homeDir, 'AppData', 'Roaming', 'mpv');
return {
- supported: platform === 'linux' || platform === 'darwin',
+ supported: platform === 'linux' || platform === 'darwin' || platform === 'win32',
mpvConfigDir,
- scriptsDir: path.join(mpvConfigDir, 'scripts'),
- scriptOptsDir: path.join(mpvConfigDir, 'script-opts'),
- pluginDir: path.join(mpvConfigDir, 'scripts', 'subminer'),
- pluginConfigPath: path.join(mpvConfigDir, 'script-opts', 'subminer.conf'),
+ scriptsDir: platformPath.join(mpvConfigDir, 'scripts'),
+ scriptOptsDir: platformPath.join(mpvConfigDir, 'script-opts'),
+ pluginEntrypointPath: platformPath.join(mpvConfigDir, 'scripts', 'subminer', 'main.lua'),
+ pluginDir: platformPath.join(mpvConfigDir, 'scripts', 'subminer'),
+ pluginConfigPath: platformPath.join(mpvConfigDir, 'script-opts', 'subminer.conf'),
};
}
diff --git a/src/subsync/utils.ts b/src/subsync/utils.ts
index 74635ca..5dc4ba5 100644
--- a/src/subsync/utils.ts
+++ b/src/subsync/utils.ts
@@ -1,5 +1,6 @@
import * as fs from 'fs';
import * as childProcess from 'child_process';
+import * as path from 'path';
import { DEFAULT_CONFIG } from '../config';
import { SubsyncConfig, SubsyncMode } from '../types';
@@ -45,6 +46,42 @@ export interface CommandResult {
error?: string;
}
+function resolveCommandInvocation(
+ executable: string,
+ args: string[],
+): { command: string; args: string[] } {
+ if (process.platform !== 'win32') {
+ return { command: executable, args };
+ }
+
+ const normalizeBashArg = (value: string): string => {
+ const normalized = value.replace(/\\/g, '/');
+ const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
+ if (!driveMatch) {
+ return normalized;
+ }
+
+ const [, driveLetter, remainder] = driveMatch;
+ return `/mnt/${driveLetter!.toLowerCase()}/${remainder}`;
+ };
+ const extension = path.extname(executable).toLowerCase();
+ if (extension === '.ps1') {
+ return {
+ command: 'powershell.exe',
+ args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', executable, ...args],
+ };
+ }
+
+ if (extension === '.sh') {
+ return {
+ command: 'bash',
+ args: [normalizeBashArg(executable), ...args.map(normalizeBashArg)],
+ };
+ }
+
+ return { command: executable, args };
+}
+
export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncResolvedConfig {
const resolvePath = (value: string | undefined, fallback: string): string => {
const trimmed = value?.trim();
@@ -108,7 +145,8 @@ export function runCommand(
timeoutMs = 120000,
): Promise {
return new Promise((resolve) => {
- const child = childProcess.spawn(executable, args, {
+ const invocation = resolveCommandInvocation(executable, args);
+ const child = childProcess.spawn(invocation.command, invocation.args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
diff --git a/src/window-trackers/base-tracker.ts b/src/window-trackers/base-tracker.ts
index 5778a80..d19172c 100644
--- a/src/window-trackers/base-tracker.ts
+++ b/src/window-trackers/base-tracker.ts
@@ -21,17 +21,31 @@ import { WindowGeometry } from '../types';
export type GeometryChangeCallback = (geometry: WindowGeometry) => void;
export type WindowFoundCallback = (geometry: WindowGeometry) => void;
export type WindowLostCallback = () => void;
-export type WindowFocusChangeCallback = (focused: boolean) => void;
export abstract class BaseWindowTracker {
protected currentGeometry: WindowGeometry | null = null;
protected windowFound: boolean = false;
- protected focusKnown: boolean = false;
- protected windowFocused: boolean = false;
+ protected targetWindowFocused: boolean = false;
public onGeometryChange: GeometryChangeCallback | null = null;
public onWindowFound: WindowFoundCallback | null = null;
public onWindowLost: WindowLostCallback | null = null;
- public onWindowFocusChange: WindowFocusChangeCallback | null = null;
+ private onWindowFocusChangeCallback: ((focused: boolean) => void) | null = null;
+
+ public get onWindowFocusChange(): ((focused: boolean) => void) | null {
+ return this.onWindowFocusChangeCallback;
+ }
+
+ public set onWindowFocusChange(callback: ((focused: boolean) => void) | null) {
+ this.onWindowFocusChangeCallback = callback;
+ }
+
+ public get onTargetWindowFocusChange(): ((focused: boolean) => void) | null {
+ return this.onWindowFocusChange;
+ }
+
+ public set onTargetWindowFocusChange(callback: ((focused: boolean) => void) | null) {
+ this.onWindowFocusChange = callback;
+ }
abstract start(): void;
abstract stop(): void;
@@ -44,23 +58,28 @@ export abstract class BaseWindowTracker {
return this.windowFound;
}
- isFocused(): boolean {
- return this.focusKnown ? this.windowFocused : this.windowFound;
+ isTargetWindowFocused(): boolean {
+ return this.targetWindowFocused;
+ }
+
+ protected updateTargetWindowFocused(focused: boolean): void {
+ if (this.targetWindowFocused === focused) {
+ return;
+ }
+
+ this.targetWindowFocused = focused;
+ this.onWindowFocusChangeCallback?.(focused);
}
protected updateFocus(focused: boolean): void {
- const changed = !this.focusKnown || this.windowFocused !== focused;
- this.focusKnown = true;
- this.windowFocused = focused;
- if (changed) {
- this.onWindowFocusChange?.(focused);
- }
+ this.updateTargetWindowFocused(focused);
}
protected updateGeometry(newGeometry: WindowGeometry | null): void {
if (newGeometry) {
if (!this.windowFound) {
this.windowFound = true;
+ this.updateTargetWindowFocused(true);
if (this.onWindowFound) this.onWindowFound(newGeometry);
}
@@ -75,14 +94,9 @@ export abstract class BaseWindowTracker {
if (this.onGeometryChange) this.onGeometryChange(newGeometry);
}
} else {
- const focusChanged = this.focusKnown && this.windowFocused;
- this.focusKnown = false;
- this.windowFocused = false;
- if (focusChanged) {
- this.onWindowFocusChange?.(false);
- }
if (this.windowFound) {
this.windowFound = false;
+ this.updateTargetWindowFocused(false);
this.currentGeometry = null;
if (this.onWindowLost) this.onWindowLost();
}
diff --git a/src/window-trackers/index.ts b/src/window-trackers/index.ts
index fb635e5..9406419 100644
--- a/src/window-trackers/index.ts
+++ b/src/window-trackers/index.ts
@@ -21,14 +21,16 @@ import { HyprlandWindowTracker } from './hyprland-tracker';
import { SwayWindowTracker } from './sway-tracker';
import { X11WindowTracker } from './x11-tracker';
import { MacOSWindowTracker } from './macos-tracker';
+import { WindowsWindowTracker } from './windows-tracker';
import { createLogger } from '../logger';
const log = createLogger('tracker');
-export type Compositor = 'hyprland' | 'sway' | 'x11' | 'macos' | null;
+export type Compositor = 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows' | null;
export type Backend = 'auto' | Exclude;
export function detectCompositor(): Compositor {
+ if (process.platform === 'win32') return 'windows';
if (process.platform === 'darwin') return 'macos';
if (process.env.HYPRLAND_INSTANCE_SIGNATURE) return 'hyprland';
if (process.env.SWAYSOCK) return 'sway';
@@ -42,6 +44,7 @@ function normalizeCompositor(value: string): Compositor | null {
if (normalized === 'sway') return 'sway';
if (normalized === 'x11') return 'x11';
if (normalized === 'macos') return 'macos';
+ if (normalized === 'windows') return 'windows';
return null;
}
@@ -70,6 +73,8 @@ export function createWindowTracker(
return new X11WindowTracker(targetMpvSocketPath?.trim() || undefined);
case 'macos':
return new MacOSWindowTracker(targetMpvSocketPath?.trim() || undefined);
+ case 'windows':
+ return new WindowsWindowTracker(targetMpvSocketPath?.trim() || undefined);
default:
log.warn('No supported compositor detected. Window tracking disabled.');
return null;
@@ -82,4 +87,5 @@ export {
SwayWindowTracker,
X11WindowTracker,
MacOSWindowTracker,
+ WindowsWindowTracker,
};
diff --git a/src/window-trackers/windows-helper.test.ts b/src/window-trackers/windows-helper.test.ts
new file mode 100644
index 0000000..76712c2
--- /dev/null
+++ b/src/window-trackers/windows-helper.test.ts
@@ -0,0 +1,111 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ parseWindowTrackerHelperFocusState,
+ parseWindowTrackerHelperOutput,
+ resolveWindowsTrackerHelper,
+} from './windows-helper';
+
+test('parseWindowTrackerHelperOutput parses helper geometry output', () => {
+ assert.deepEqual(parseWindowTrackerHelperOutput('120,240,1280,720'), {
+ x: 120,
+ y: 240,
+ width: 1280,
+ height: 720,
+ });
+});
+
+test('parseWindowTrackerHelperOutput returns null for misses and invalid payloads', () => {
+ assert.equal(parseWindowTrackerHelperOutput('not-found'), null);
+ assert.equal(parseWindowTrackerHelperOutput('1,2,3'), null);
+ assert.equal(parseWindowTrackerHelperOutput('1,2,0,4'), null);
+});
+
+test('parseWindowTrackerHelperFocusState parses helper stderr metadata', () => {
+ assert.equal(parseWindowTrackerHelperFocusState('focus=focused'), true);
+ assert.equal(parseWindowTrackerHelperFocusState('focus=not-focused'), false);
+ assert.equal(parseWindowTrackerHelperFocusState('warning\nfocus=focused\nnote'), true);
+ assert.equal(parseWindowTrackerHelperFocusState(''), null);
+});
+
+test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => {
+ const helper = resolveWindowsTrackerHelper({
+ dirname: 'C:\\repo\\dist\\window-trackers',
+ resourcesPath: 'C:\\repo\\resources',
+ existsSync: (candidate) =>
+ candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
+ helperModeEnv: 'auto',
+ });
+
+ assert.deepEqual(helper, {
+ kind: 'native',
+ command: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
+ args: [],
+ helperPath: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
+ });
+});
+
+test('resolveWindowsTrackerHelper auto mode falls back to powershell helper', () => {
+ const helper = resolveWindowsTrackerHelper({
+ dirname: 'C:\\repo\\dist\\window-trackers',
+ resourcesPath: 'C:\\repo\\resources',
+ existsSync: (candidate) =>
+ candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
+ helperModeEnv: 'auto',
+ });
+
+ assert.deepEqual(helper, {
+ kind: 'powershell',
+ command: 'powershell.exe',
+ args: [
+ '-NoProfile',
+ '-ExecutionPolicy',
+ 'Bypass',
+ '-File',
+ 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
+ ],
+ helperPath: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
+ });
+});
+
+test('resolveWindowsTrackerHelper explicit powershell mode ignores native helper', () => {
+ const helper = resolveWindowsTrackerHelper({
+ dirname: 'C:\\repo\\dist\\window-trackers',
+ resourcesPath: 'C:\\repo\\resources',
+ existsSync: (candidate) =>
+ candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe' ||
+ candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
+ helperModeEnv: 'powershell',
+ });
+
+ assert.equal(helper?.kind, 'powershell');
+ assert.equal(helper?.helperPath, 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1');
+});
+
+test('resolveWindowsTrackerHelper explicit native mode fails cleanly when helper is missing', () => {
+ const helper = resolveWindowsTrackerHelper({
+ dirname: 'C:\\repo\\dist\\window-trackers',
+ resourcesPath: 'C:\\repo\\resources',
+ existsSync: () => false,
+ helperModeEnv: 'native',
+ });
+
+ assert.equal(helper, null);
+});
+
+test('resolveWindowsTrackerHelper explicit helper path overrides default search', () => {
+ const helper = resolveWindowsTrackerHelper({
+ dirname: 'C:\\repo\\dist\\window-trackers',
+ resourcesPath: 'C:\\repo\\resources',
+ existsSync: (candidate) => candidate === 'D:\\custom\\tracker.ps1',
+ helperModeEnv: 'auto',
+ helperPathEnv: 'D:\\custom\\tracker.ps1',
+ });
+
+ assert.deepEqual(helper, {
+ kind: 'powershell',
+ command: 'powershell.exe',
+ args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'D:\\custom\\tracker.ps1'],
+ helperPath: 'D:\\custom\\tracker.ps1',
+ });
+});
diff --git a/src/window-trackers/windows-helper.ts b/src/window-trackers/windows-helper.ts
new file mode 100644
index 0000000..24975ed
--- /dev/null
+++ b/src/window-trackers/windows-helper.ts
@@ -0,0 +1,284 @@
+/*
+ SubMiner - All-in-one sentence mining overlay
+ Copyright (C) 2024 sudacode
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import type { WindowGeometry } from '../types';
+import { createLogger } from '../logger';
+
+const log = createLogger('tracker').child('windows-helper');
+
+export type WindowsTrackerHelperKind = 'powershell' | 'native';
+export type WindowsTrackerHelperMode = 'auto' | 'powershell' | 'native';
+
+export type WindowsTrackerHelperLaunchSpec = {
+ kind: WindowsTrackerHelperKind;
+ command: string;
+ args: string[];
+ helperPath: string;
+};
+
+type ResolveWindowsTrackerHelperOptions = {
+ dirname?: string;
+ resourcesPath?: string;
+ helperModeEnv?: string | undefined;
+ helperPathEnv?: string | undefined;
+ existsSync?: (candidate: string) => boolean;
+ mkdirSync?: (candidate: string, options: { recursive: true }) => void;
+ copyFileSync?: (source: string, destination: string) => void;
+};
+
+const windowsPath = path.win32;
+
+function normalizeHelperMode(value: string | undefined): WindowsTrackerHelperMode {
+ const normalized = value?.trim().toLowerCase();
+ if (normalized === 'powershell' || normalized === 'native') {
+ return normalized;
+ }
+ return 'auto';
+}
+
+function inferHelperKindFromPath(helperPath: string): WindowsTrackerHelperKind | null {
+ const normalized = helperPath.trim().toLowerCase();
+ if (normalized.endsWith('.exe')) return 'native';
+ if (normalized.endsWith('.ps1')) return 'powershell';
+ return null;
+}
+
+function materializeAsarHelper(
+ sourcePath: string,
+ kind: WindowsTrackerHelperKind,
+ deps: Required<
+ Pick
+ >,
+): string | null {
+ if (!sourcePath.includes('.asar')) {
+ return sourcePath;
+ }
+
+ const fileName =
+ kind === 'native' ? 'get-mpv-window-windows.exe' : 'get-mpv-window-windows.ps1';
+ const targetDir = windowsPath.join(os.tmpdir(), 'subminer', 'helpers');
+ const targetPath = windowsPath.join(targetDir, fileName);
+
+ try {
+ deps.mkdirSync(targetDir, { recursive: true });
+ deps.copyFileSync(sourcePath, targetPath);
+ log.info(`Materialized Windows helper from asar: ${targetPath}`);
+ return targetPath;
+ } catch (error) {
+ log.warn(`Failed to materialize Windows helper from asar: ${sourcePath}`, error);
+ return null;
+ }
+}
+
+function createLaunchSpec(
+ helperPath: string,
+ kind: WindowsTrackerHelperKind,
+): WindowsTrackerHelperLaunchSpec {
+ if (kind === 'native') {
+ return {
+ kind,
+ command: helperPath,
+ args: [],
+ helperPath,
+ };
+ }
+
+ return {
+ kind,
+ command: 'powershell.exe',
+ args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', helperPath],
+ helperPath,
+ };
+}
+
+function normalizeHelperPathOverride(
+ helperPathEnv: string | undefined,
+ mode: WindowsTrackerHelperMode,
+): { path: string; kind: WindowsTrackerHelperKind } | null {
+ const helperPath = helperPathEnv?.trim();
+ if (!helperPath) {
+ return null;
+ }
+
+ const inferredKind = inferHelperKindFromPath(helperPath);
+ const kind = mode === 'auto' ? inferredKind : mode;
+ if (!kind) {
+ log.warn(
+ `Ignoring SUBMINER_WINDOWS_TRACKER_HELPER_PATH with unsupported extension: ${helperPath}`,
+ );
+ return null;
+ }
+
+ return { path: helperPath, kind };
+}
+
+function getHelperCandidates(dirname: string, resourcesPath: string | undefined): Array<{
+ path: string;
+ kind: WindowsTrackerHelperKind;
+}> {
+ const scriptFileBase = 'get-mpv-window-windows';
+ const candidates: Array<{ path: string; kind: WindowsTrackerHelperKind }> = [];
+
+ if (resourcesPath) {
+ candidates.push({
+ path: windowsPath.join(resourcesPath, 'scripts', `${scriptFileBase}.exe`),
+ kind: 'native',
+ });
+ candidates.push({
+ path: windowsPath.join(resourcesPath, 'scripts', `${scriptFileBase}.ps1`),
+ kind: 'powershell',
+ });
+ }
+
+ candidates.push({
+ path: windowsPath.join(dirname, '..', 'scripts', `${scriptFileBase}.exe`),
+ kind: 'native',
+ });
+ candidates.push({
+ path: windowsPath.join(dirname, '..', 'scripts', `${scriptFileBase}.ps1`),
+ kind: 'powershell',
+ });
+ candidates.push({
+ path: windowsPath.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.exe`),
+ kind: 'native',
+ });
+ candidates.push({
+ path: windowsPath.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.ps1`),
+ kind: 'powershell',
+ });
+
+ return candidates;
+}
+
+export function parseWindowTrackerHelperOutput(output: string): WindowGeometry | null {
+ const result = output.trim();
+ if (!result || result === 'not-found') {
+ return null;
+ }
+
+ const parts = result.split(',');
+ if (parts.length !== 4) {
+ return null;
+ }
+
+ const [xText, yText, widthText, heightText] = parts;
+ const x = Number.parseInt(xText!, 10);
+ const y = Number.parseInt(yText!, 10);
+ const width = Number.parseInt(widthText!, 10);
+ const height = Number.parseInt(heightText!, 10);
+ if (
+ !Number.isFinite(x) ||
+ !Number.isFinite(y) ||
+ !Number.isFinite(width) ||
+ !Number.isFinite(height) ||
+ width <= 0 ||
+ height <= 0
+ ) {
+ return null;
+ }
+
+ return { x, y, width, height };
+}
+
+export function parseWindowTrackerHelperFocusState(output: string): boolean | null {
+ const focusLine = output
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .find((line) => line.startsWith('focus='));
+
+ if (!focusLine) {
+ return null;
+ }
+
+ const value = focusLine.slice('focus='.length).trim().toLowerCase();
+ if (value === 'focused') {
+ return true;
+ }
+ if (value === 'not-focused') {
+ return false;
+ }
+
+ return null;
+}
+
+export function resolveWindowsTrackerHelper(
+ options: ResolveWindowsTrackerHelperOptions = {},
+): WindowsTrackerHelperLaunchSpec | null {
+ const existsSync = options.existsSync ?? fs.existsSync;
+ const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
+ const copyFileSync = options.copyFileSync ?? fs.copyFileSync;
+ const dirname = options.dirname ?? __dirname;
+ const resourcesPath = options.resourcesPath ?? process.resourcesPath;
+ const mode = normalizeHelperMode(
+ options.helperModeEnv ?? process.env.SUBMINER_WINDOWS_TRACKER_HELPER,
+ );
+ const override = normalizeHelperPathOverride(
+ options.helperPathEnv ?? process.env.SUBMINER_WINDOWS_TRACKER_HELPER_PATH,
+ mode,
+ );
+
+ if (override) {
+ if (!existsSync(override.path)) {
+ log.warn(`Configured Windows tracker helper path does not exist: ${override.path}`);
+ return null;
+ }
+ const helperPath = materializeAsarHelper(override.path, override.kind, {
+ mkdirSync,
+ copyFileSync,
+ });
+ return helperPath ? createLaunchSpec(helperPath, override.kind) : null;
+ }
+
+ const candidates = getHelperCandidates(dirname, resourcesPath);
+ const orderedCandidates =
+ mode === 'powershell'
+ ? candidates.filter((candidate) => candidate.kind === 'powershell')
+ : mode === 'native'
+ ? candidates.filter((candidate) => candidate.kind === 'native')
+ : candidates;
+
+ for (const candidate of orderedCandidates) {
+ if (!existsSync(candidate.path)) {
+ continue;
+ }
+
+ const helperPath = materializeAsarHelper(candidate.path, candidate.kind, {
+ mkdirSync,
+ copyFileSync,
+ });
+ if (!helperPath) {
+ continue;
+ }
+
+ log.info(`Using Windows helper (${candidate.kind}): ${helperPath}`);
+ return createLaunchSpec(helperPath, candidate.kind);
+ }
+
+ if (mode === 'native') {
+ log.warn('Windows native tracker helper requested but no helper was found.');
+ } else if (mode === 'powershell') {
+ log.warn('Windows PowerShell tracker helper requested but no helper was found.');
+ } else {
+ log.warn('Windows tracker helper not found.');
+ }
+
+ return null;
+}
diff --git a/src/window-trackers/windows-tracker.test.ts b/src/window-trackers/windows-tracker.test.ts
new file mode 100644
index 0000000..2b643bd
--- /dev/null
+++ b/src/window-trackers/windows-tracker.test.ts
@@ -0,0 +1,119 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { WindowsWindowTracker } from './windows-tracker';
+
+test('WindowsWindowTracker skips overlapping polls while helper is in flight', async () => {
+ let helperCalls = 0;
+ let release: (() => void) | undefined;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+
+ const tracker = new WindowsWindowTracker(undefined, {
+ resolveHelper: () => ({
+ kind: 'powershell',
+ command: 'powershell.exe',
+ args: ['-File', 'helper.ps1'],
+ helperPath: 'helper.ps1',
+ }),
+ runHelper: async () => {
+ helperCalls += 1;
+ await gate;
+ return {
+ stdout: '0,0,640,360',
+ stderr: 'focus=focused',
+ };
+ },
+ });
+
+ (tracker as unknown as { pollGeometry: () => void }).pollGeometry();
+ (tracker as unknown as { pollGeometry: () => void }).pollGeometry();
+ assert.equal(helperCalls, 1);
+
+ assert.ok(release);
+ release();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+});
+
+test('WindowsWindowTracker updates geometry from helper output', async () => {
+ const tracker = new WindowsWindowTracker(undefined, {
+ resolveHelper: () => ({
+ kind: 'powershell',
+ command: 'powershell.exe',
+ args: ['-File', 'helper.ps1'],
+ helperPath: 'helper.ps1',
+ }),
+ runHelper: async () => ({
+ stdout: '10,20,1280,720',
+ stderr: 'focus=focused',
+ }),
+ });
+
+ (tracker as unknown as { pollGeometry: () => void }).pollGeometry();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ assert.deepEqual(tracker.getGeometry(), {
+ x: 10,
+ y: 20,
+ width: 1280,
+ height: 720,
+ });
+ assert.equal(tracker.isTargetWindowFocused(), true);
+});
+
+test('WindowsWindowTracker clears geometry for helper misses', async () => {
+ const tracker = new WindowsWindowTracker(undefined, {
+ resolveHelper: () => ({
+ kind: 'powershell',
+ command: 'powershell.exe',
+ args: ['-File', 'helper.ps1'],
+ helperPath: 'helper.ps1',
+ }),
+ runHelper: async () => ({
+ stdout: 'not-found',
+ stderr: 'focus=not-focused',
+ }),
+ });
+
+ (tracker as unknown as { pollGeometry: () => void }).pollGeometry();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.equal(tracker.getGeometry(), null);
+ assert.equal(tracker.isTargetWindowFocused(), false);
+});
+
+test('WindowsWindowTracker retries without socket filter when filtered helper lookup misses', async () => {
+ const helperCalls: Array = [];
+ const tracker = new WindowsWindowTracker('\\\\.\\pipe\\subminer-socket', {
+ resolveHelper: () => ({
+ kind: 'powershell',
+ command: 'powershell.exe',
+ args: ['-File', 'helper.ps1'],
+ helperPath: 'helper.ps1',
+ }),
+ runHelper: async (_spec, _mode, targetMpvSocketPath) => {
+ helperCalls.push(targetMpvSocketPath);
+ if (targetMpvSocketPath) {
+ return {
+ stdout: 'not-found',
+ stderr: 'focus=not-focused',
+ };
+ }
+ return {
+ stdout: '25,30,1440,810',
+ stderr: 'focus=focused',
+ };
+ },
+ });
+
+ (tracker as unknown as { pollGeometry: () => void }).pollGeometry();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ assert.deepEqual(helperCalls, ['\\\\.\\pipe\\subminer-socket', null]);
+ assert.deepEqual(tracker.getGeometry(), {
+ x: 25,
+ y: 30,
+ width: 1440,
+ height: 810,
+ });
+ assert.equal(tracker.isTargetWindowFocused(), true);
+});
diff --git a/src/window-trackers/windows-tracker.ts b/src/window-trackers/windows-tracker.ts
new file mode 100644
index 0000000..f291571
--- /dev/null
+++ b/src/window-trackers/windows-tracker.ts
@@ -0,0 +1,176 @@
+/*
+ SubMiner - All-in-one sentence mining overlay
+ Copyright (C) 2024 sudacode
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+import { execFile, type ExecFileException } from 'child_process';
+import { BaseWindowTracker } from './base-tracker';
+import {
+ parseWindowTrackerHelperFocusState,
+ parseWindowTrackerHelperOutput,
+ resolveWindowsTrackerHelper,
+ type WindowsTrackerHelperLaunchSpec,
+} from './windows-helper';
+import { createLogger } from '../logger';
+
+const log = createLogger('tracker').child('windows');
+
+type WindowsTrackerRunnerResult = {
+ stdout: string;
+ stderr: string;
+};
+
+type WindowsTrackerDeps = {
+ resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
+ runHelper?: (
+ spec: WindowsTrackerHelperLaunchSpec,
+ mode: 'geometry',
+ targetMpvSocketPath: string | null,
+ ) => Promise;
+};
+
+function runHelperWithExecFile(
+ spec: WindowsTrackerHelperLaunchSpec,
+ mode: 'geometry',
+ targetMpvSocketPath: string | null,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const modeArgs =
+ spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode];
+ const args = targetMpvSocketPath
+ ? [...spec.args, ...modeArgs, targetMpvSocketPath]
+ : [...spec.args, ...modeArgs];
+ execFile(
+ spec.command,
+ args,
+ {
+ encoding: 'utf-8',
+ timeout: 1000,
+ maxBuffer: 1024 * 1024,
+ windowsHide: true,
+ },
+ (error: ExecFileException | null, stdout: string, stderr: string) => {
+ if (error) {
+ reject(Object.assign(error, { stderr }));
+ return;
+ }
+ resolve({ stdout, stderr });
+ },
+ );
+ });
+}
+
+export class WindowsWindowTracker extends BaseWindowTracker {
+ private pollInterval: ReturnType | null = null;
+ private pollInFlight = false;
+ private helperSpec: WindowsTrackerHelperLaunchSpec | null;
+ private readonly targetMpvSocketPath: string | null;
+ private readonly runHelper: (
+ spec: WindowsTrackerHelperLaunchSpec,
+ mode: 'geometry',
+ targetMpvSocketPath: string | null,
+ ) => Promise;
+ private lastExecErrorFingerprint: string | null = null;
+ private lastExecErrorLoggedAtMs = 0;
+
+ constructor(targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
+ super();
+ this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
+ this.helperSpec = deps.resolveHelper ? deps.resolveHelper() : resolveWindowsTrackerHelper();
+ this.runHelper = deps.runHelper ?? runHelperWithExecFile;
+ }
+
+ start(): void {
+ this.pollInterval = setInterval(() => this.pollGeometry(), 250);
+ this.pollGeometry();
+ }
+
+ stop(): void {
+ if (this.pollInterval) {
+ clearInterval(this.pollInterval);
+ this.pollInterval = null;
+ }
+ }
+
+ private maybeLogExecError(error: Error, stderr: string): void {
+ const now = Date.now();
+ const fingerprint = `${error.message}|${stderr.trim()}`;
+ const shouldLog =
+ this.lastExecErrorFingerprint !== fingerprint || now - this.lastExecErrorLoggedAtMs >= 5000;
+ if (!shouldLog) {
+ return;
+ }
+
+ this.lastExecErrorFingerprint = fingerprint;
+ this.lastExecErrorLoggedAtMs = now;
+ log.warn('Windows helper execution failed', {
+ helperPath: this.helperSpec?.helperPath ?? null,
+ helperKind: this.helperSpec?.kind ?? null,
+ error: error.message,
+ stderr: stderr.trim(),
+ });
+ }
+
+ private async runHelperWithSocketFallback(): Promise {
+ if (!this.helperSpec) {
+ return { stdout: 'not-found', stderr: '' };
+ }
+
+ try {
+ const primary = await this.runHelper(this.helperSpec, 'geometry', this.targetMpvSocketPath);
+ const primaryGeometry = parseWindowTrackerHelperOutput(primary.stdout);
+ if (primaryGeometry || !this.targetMpvSocketPath) {
+ return primary;
+ }
+ } catch (error) {
+ if (!this.targetMpvSocketPath) {
+ throw error;
+ }
+ }
+
+ return await this.runHelper(this.helperSpec, 'geometry', null);
+ }
+
+ private pollGeometry(): void {
+ if (this.pollInFlight || !this.helperSpec) {
+ return;
+ }
+
+ this.pollInFlight = true;
+ void this.runHelperWithSocketFallback()
+ .then(({ stdout, stderr }) => {
+ const geometry = parseWindowTrackerHelperOutput(stdout);
+ const focusState = parseWindowTrackerHelperFocusState(stderr);
+ this.updateTargetWindowFocused(focusState ?? Boolean(geometry));
+ this.updateGeometry(geometry);
+ })
+ .catch((error: unknown) => {
+ const err = error instanceof Error ? error : new Error(String(error));
+ const stderr =
+ typeof error === 'object' &&
+ error !== null &&
+ 'stderr' in error &&
+ typeof (error as { stderr?: unknown }).stderr === 'string'
+ ? (error as { stderr: string }).stderr
+ : '';
+ this.maybeLogExecError(err, stderr);
+ this.updateGeometry(null);
+ })
+ .finally(() => {
+ this.pollInFlight = false;
+ });
+ }
+}
diff --git a/vendor/subminer-yomitan b/vendor/subminer-yomitan
index 66cb7a0..9863d86 160000
--- a/vendor/subminer-yomitan
+++ b/vendor/subminer-yomitan
@@ -1 +1 @@
-Subproject commit 66cb7a06f1f6a097d5ff5e704617ff755817711a
+Subproject commit 9863d865e14fc1c9df4f1a7be3a541d557873d2e
diff --git a/vendor/texthooker-ui b/vendor/texthooker-ui
index e8c7ae1..534cd66 160000
--- a/vendor/texthooker-ui
+++ b/vendor/texthooker-ui
@@ -1 +1 @@
-Subproject commit e8c7ae1122e6d0635770225f2f853d8b00ed488c
+Subproject commit 534cd66b6ea3d52875acbd871d1013ba49034b72