mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-27 04:19:27 -07:00
Windows update (#49)
This commit is contained in:
@@ -23,7 +23,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
{ kind: string },
|
||||
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
||||
{ registry: boolean },
|
||||
{ getModalWindow: () => null },
|
||||
{ getMainWindow: () => null; getModalWindow: () => null },
|
||||
{
|
||||
inputState: boolean;
|
||||
getModalInputExclusive: () => boolean;
|
||||
@@ -82,6 +82,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
}) as const,
|
||||
createMainRuntimeRegistry: () => ({ registry: true }),
|
||||
createOverlayManager: () => ({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
}),
|
||||
createOverlayModalInputState: () => ({
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface MainBootServicesParams<
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
restoreMainWindowFocus?: () => void;
|
||||
}) => TOverlayModalInputState;
|
||||
createOverlayContentMeasurementStore: (params: {
|
||||
logger: TLogger;
|
||||
@@ -131,7 +132,7 @@ export function createMainBootServices<
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager extends { getModalWindow: () => BrowserWindow | null },
|
||||
TOverlayManager extends { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null },
|
||||
TOverlayModalInputState extends OverlayModalInputStateShape,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
@@ -212,6 +213,26 @@ export function createMainBootServices<
|
||||
syncOverlayVisibilityForModal: () => {
|
||||
params.getSyncOverlayVisibilityForModal()();
|
||||
},
|
||||
restoreMainWindowFocus: () => {
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) return;
|
||||
try {
|
||||
const electron = require('electron') as {
|
||||
app?: { focus?: (options?: { steal?: boolean }) => void };
|
||||
};
|
||||
electron.app?.focus?.({ steal: true });
|
||||
} catch {
|
||||
// Ignore in non-Electron environments.
|
||||
}
|
||||
const maybeFocusable = mainWindow as typeof mainWindow & {
|
||||
setFocusable?: (focusable: boolean) => void;
|
||||
};
|
||||
maybeFocusable.setFocusable?.(true);
|
||||
mainWindow.focus();
|
||||
if (!mainWindow.webContents.isFocused()) {
|
||||
mainWindow.webContents.focus();
|
||||
}
|
||||
},
|
||||
});
|
||||
const overlayContentMeasurementStore = params.createOverlayContentMeasurementStore({
|
||||
logger,
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface CliCommandRuntimeServiceContext {
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
dispatchSessionAction: CliCommandRuntimeServiceDepsParams['dispatchSessionAction'];
|
||||
getAnilistStatus: CliCommandRuntimeServiceDepsParams['anilist']['getStatus'];
|
||||
clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken'];
|
||||
openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup'];
|
||||
@@ -113,6 +114,7 @@ function createCliCommandDepsFromContext(
|
||||
hasMainWindow: context.hasMainWindow,
|
||||
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
||||
},
|
||||
dispatchSessionAction: context.dispatchSessionAction,
|
||||
ui: {
|
||||
openFirstRunSetup: context.openFirstRunSetup,
|
||||
openYomitanSettings: context.openYomitanSettings,
|
||||
|
||||
@@ -73,7 +73,9 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer'];
|
||||
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||
getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings'];
|
||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
|
||||
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
|
||||
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
|
||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
||||
@@ -178,6 +180,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
||||
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
||||
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
||||
};
|
||||
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
|
||||
ui: {
|
||||
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
|
||||
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
|
||||
@@ -233,7 +236,9 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getMecabTokenizer: params.getMecabTokenizer,
|
||||
handleMpvCommand: params.handleMpvCommand,
|
||||
getKeybindings: params.getKeybindings,
|
||||
getSessionBindings: params.getSessionBindings,
|
||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||
dispatchSessionAction: params.dispatchSessionAction,
|
||||
getStatsToggleKey: params.getStatsToggleKey,
|
||||
getMarkWatchedKey: params.getMarkWatchedKey,
|
||||
getControllerConfig: params.getControllerConfig,
|
||||
@@ -347,6 +352,7 @@ export function createCliCommandRuntimeServiceDeps(
|
||||
hasMainWindow: params.app.hasMainWindow,
|
||||
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
||||
},
|
||||
dispatchSessionAction: params.dispatchSessionAction,
|
||||
ui: {
|
||||
openFirstRunSetup: params.ui.openFirstRunSetup,
|
||||
openYomitanSettings: params.ui.openYomitanSettings,
|
||||
|
||||
@@ -7,13 +7,16 @@ type MockWindow = {
|
||||
visible: boolean;
|
||||
focused: boolean;
|
||||
ignoreMouseEvents: boolean;
|
||||
forwardedIgnoreMouseEvents: boolean;
|
||||
webContentsFocused: boolean;
|
||||
showCount: number;
|
||||
hideCount: number;
|
||||
sent: unknown[][];
|
||||
loading: boolean;
|
||||
url: string;
|
||||
contentReady: boolean;
|
||||
loadCallbacks: Array<() => void>;
|
||||
readyToShowCallbacks: Array<() => void>;
|
||||
};
|
||||
|
||||
function createMockWindow(): MockWindow & {
|
||||
@@ -28,7 +31,11 @@ function createMockWindow(): MockWindow & {
|
||||
getHideCount: () => number;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
destroy: () => void;
|
||||
focus: () => void;
|
||||
emitDidFinishLoad: () => void;
|
||||
emitReadyToShow: () => void;
|
||||
once: (event: 'ready-to-show', cb: () => void) => void;
|
||||
webContents: {
|
||||
focused: boolean;
|
||||
isLoading: () => boolean;
|
||||
@@ -44,13 +51,16 @@ function createMockWindow(): MockWindow & {
|
||||
visible: false,
|
||||
focused: false,
|
||||
ignoreMouseEvents: false,
|
||||
forwardedIgnoreMouseEvents: false,
|
||||
webContentsFocused: false,
|
||||
showCount: 0,
|
||||
hideCount: 0,
|
||||
sent: [],
|
||||
loading: false,
|
||||
url: 'file:///overlay/index.html?layer=modal',
|
||||
contentReady: true,
|
||||
loadCallbacks: [],
|
||||
readyToShowCallbacks: [],
|
||||
};
|
||||
const window = {
|
||||
...state,
|
||||
@@ -58,8 +68,9 @@ function createMockWindow(): MockWindow & {
|
||||
isVisible: () => state.visible,
|
||||
isFocused: () => state.focused,
|
||||
getURL: () => state.url,
|
||||
setIgnoreMouseEvents: (ignore: boolean, _options?: { forward?: boolean }) => {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
state.ignoreMouseEvents = ignore;
|
||||
state.forwardedIgnoreMouseEvents = options?.forward === true;
|
||||
},
|
||||
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {},
|
||||
moveTop: () => {},
|
||||
@@ -73,9 +84,28 @@ function createMockWindow(): MockWindow & {
|
||||
state.visible = false;
|
||||
state.hideCount += 1;
|
||||
},
|
||||
destroy: () => {
|
||||
state.destroyed = true;
|
||||
state.visible = false;
|
||||
},
|
||||
focus: () => {
|
||||
state.focused = true;
|
||||
},
|
||||
emitDidFinishLoad: () => {
|
||||
const callbacks = state.loadCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
emitReadyToShow: () => {
|
||||
const callbacks = state.readyToShowCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
once: (_event: 'ready-to-show', cb: () => void) => {
|
||||
state.readyToShowCallbacks.push(cb);
|
||||
},
|
||||
webContents: {
|
||||
isLoading: () => state.loading,
|
||||
getURL: () => state.url,
|
||||
@@ -139,6 +169,25 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'forwardedIgnoreMouseEvents', {
|
||||
get: () => state.forwardedIgnoreMouseEvents,
|
||||
set: (value: boolean) => {
|
||||
state.forwardedIgnoreMouseEvents = value;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'contentReady', {
|
||||
get: () => state.contentReady,
|
||||
set: (value: boolean) => {
|
||||
state.contentReady = value;
|
||||
(window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady =
|
||||
value;
|
||||
},
|
||||
});
|
||||
|
||||
(window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady =
|
||||
state.contentReady;
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
@@ -195,10 +244,29 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
|
||||
assert.deepEqual(window.sent, [['jimaku:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => {
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
createModalWindow: () => null,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), false);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow waits for blank modal URL before sending open command', () => {
|
||||
const window = createMockWindow();
|
||||
window.url = '';
|
||||
window.loading = true;
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
@@ -215,11 +283,13 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
assert.equal(window.loadCallbacks.length, 1);
|
||||
window.loading = false;
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.loadCallbacks[0]!();
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
@@ -248,10 +318,10 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
|
||||
);
|
||||
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
assert.equal(window.getHideCount(), 0);
|
||||
assert.equal(window.isDestroyed(), false);
|
||||
|
||||
runtime.handleOverlayModalClosed('subsync');
|
||||
assert.equal(window.getHideCount(), 1);
|
||||
assert.equal(window.isDestroyed(), true);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => {
|
||||
@@ -325,11 +395,12 @@ test('modal window path makes visible main overlay click-through until modal clo
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(mainWindow.ignoreMouseEvents, true);
|
||||
assert.equal(mainWindow.forwardedIgnoreMouseEvents, true);
|
||||
assert.equal(modalWindow.ignoreMouseEvents, false);
|
||||
|
||||
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||
|
||||
assert.equal(mainWindow.ignoreMouseEvents, false);
|
||||
assert.equal(mainWindow.ignoreMouseEvents, true);
|
||||
});
|
||||
|
||||
test('modal window path hides visible main overlay until modal closes', () => {
|
||||
@@ -359,8 +430,8 @@ test('modal window path hides visible main overlay until modal closes', () => {
|
||||
|
||||
runtime.handleOverlayModalClosed('youtube-track-picker');
|
||||
|
||||
assert.equal(mainWindow.getShowCount(), 1);
|
||||
assert.equal(mainWindow.isVisible(), true);
|
||||
assert.equal(mainWindow.getShowCount(), 0);
|
||||
assert.equal(mainWindow.isVisible(), false);
|
||||
});
|
||||
|
||||
test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
|
||||
@@ -437,7 +508,7 @@ test('notifyOverlayModalOpened enables input on visible main overlay window when
|
||||
assert.equal(mainWindow.webContentsFocused, true);
|
||||
});
|
||||
|
||||
test('handleOverlayModalClosed resets modal state even when modal window does not exist', () => {
|
||||
test('handleOverlayModalClosed is a no-op when no modal window can be targeted', () => {
|
||||
const state: boolean[] = [];
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
@@ -454,16 +525,17 @@ test('handleOverlayModalClosed resets modal state even when modal window does no
|
||||
},
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
assert.equal(sent, false);
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.deepEqual(state, [true, false]);
|
||||
assert.deepEqual(state, []);
|
||||
});
|
||||
|
||||
test('handleOverlayModalClosed hides modal window for single kiku modal', () => {
|
||||
test('handleOverlayModalClosed destroys modal window for single kiku modal', () => {
|
||||
const window = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
@@ -482,12 +554,56 @@ test('handleOverlayModalClosed hides modal window for single kiku modal', () =>
|
||||
);
|
||||
runtime.handleOverlayModalClosed('kiku');
|
||||
|
||||
assert.equal(window.getHideCount(), 1);
|
||||
assert.equal(window.isDestroyed(), true);
|
||||
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0);
|
||||
});
|
||||
|
||||
test('modal fallback reveal keeps mouse events ignored until modal confirms open', async () => {
|
||||
test('modal fallback reveal skips showing window when content is not ready', async () => {
|
||||
const window = createMockWindow();
|
||||
let scheduledReveal: (() => void) | null = null;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => {
|
||||
throw new Error('modal window should not be created when already present');
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
}, {
|
||||
scheduleRevealFallback: (callback) => {
|
||||
scheduledReveal = callback;
|
||||
return { scheduled: true } as never;
|
||||
},
|
||||
clearRevealFallback: () => {
|
||||
scheduledReveal = null;
|
||||
},
|
||||
});
|
||||
|
||||
window.loading = true;
|
||||
window.url = '';
|
||||
window.contentReady = false;
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, {
|
||||
restoreOnModalClose: 'jimaku',
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
if (scheduledReveal === null) {
|
||||
throw new Error('expected reveal callback');
|
||||
}
|
||||
const runScheduledReveal: () => void = scheduledReveal;
|
||||
runScheduledReveal();
|
||||
|
||||
assert.equal(window.getShowCount(), 0);
|
||||
|
||||
runtime.notifyOverlayModalOpened('jimaku');
|
||||
assert.equal(window.getShowCount(), 1);
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering open event', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
@@ -498,32 +614,162 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
window.loading = true;
|
||||
window.url = '';
|
||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, {
|
||||
restoreOnModalClose: 'jimaku',
|
||||
assert.equal(sent, true);
|
||||
assert.deepEqual(window.sent, []);
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow flushes every queued load and ready listener before sending', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => {
|
||||
throw new Error('modal window should not be created when already present');
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
|
||||
restoreOnModalClose: 'session-help',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
|
||||
});
|
||||
|
||||
test('modal reopen creates a fresh window after close destroys the previous one', () => {
|
||||
const firstWindow = createMockWindow();
|
||||
const secondWindow = createMockWindow();
|
||||
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
|
||||
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
createModalWindow: () => {
|
||||
currentModal = secondWindow;
|
||||
return secondWindow as never;
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.equal(firstWindow.isDestroyed(), true);
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(currentModal, secondWindow);
|
||||
assert.equal(secondWindow.getShowCount(), 0);
|
||||
});
|
||||
|
||||
test('modal reopen after close-destroy notifies state change on fresh window lifecycle', () => {
|
||||
const firstWindow = createMockWindow();
|
||||
const secondWindow = createMockWindow();
|
||||
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
|
||||
const state: boolean[] = [];
|
||||
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
createModalWindow: () => {
|
||||
currentModal = secondWindow;
|
||||
return secondWindow as never;
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{
|
||||
onModalStateChange: (active: boolean): void => {
|
||||
state.push(active);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.deepEqual(state, [true, false]);
|
||||
assert.equal(firstWindow.isDestroyed(), true);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
|
||||
assert.deepEqual(state, [true, false, true]);
|
||||
assert.equal(currentModal, secondWindow);
|
||||
});
|
||||
|
||||
test('visible stale modal window is made interactive again before reopening', () => {
|
||||
const window = createMockWindow();
|
||||
window.visible = true;
|
||||
window.focused = true;
|
||||
window.webContentsFocused = false;
|
||||
window.ignoreMouseEvents = true;
|
||||
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 260);
|
||||
});
|
||||
|
||||
assert.equal(window.getShowCount(), 1);
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
|
||||
runtime.notifyOverlayModalOpened('jimaku');
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
assert.equal(window.isFocused(), true);
|
||||
assert.equal(window.webContentsFocused, true);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('waitForModalOpen resolves true after modal acknowledgement', async () => {
|
||||
const modalWindow = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
createModalWindow: () => null,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
|
||||
|
||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||
|
||||
function requestOverlayApplicationFocus(): void {
|
||||
try {
|
||||
const electron = require('electron') as {
|
||||
app?: {
|
||||
focus?: (options?: { steal?: boolean }) => void;
|
||||
};
|
||||
};
|
||||
electron.app?.focus?.({ steal: true });
|
||||
} catch {
|
||||
// Ignore focus-steal failures in non-Electron test environments.
|
||||
}
|
||||
}
|
||||
|
||||
function setWindowFocusable(window: BrowserWindow): void {
|
||||
const maybeFocusableWindow = window as BrowserWindow & {
|
||||
setFocusable?: (focusable: boolean) => void;
|
||||
};
|
||||
maybeFocusableWindow.setFocusable?.(true);
|
||||
}
|
||||
|
||||
export interface OverlayWindowResolver {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
@@ -29,8 +50,15 @@ export interface OverlayModalRuntime {
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
}
|
||||
|
||||
type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
|
||||
|
||||
export interface OverlayModalRuntimeOptions {
|
||||
onModalStateChange?: (isActive: boolean) => void;
|
||||
scheduleRevealFallback?: (
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
) => RevealFallbackHandle;
|
||||
clearRevealFallback?: (timeout: RevealFallbackHandle) => void;
|
||||
}
|
||||
|
||||
export function createOverlayModalRuntimeService(
|
||||
@@ -42,8 +70,16 @@ export function createOverlayModalRuntimeService(
|
||||
let modalActive = false;
|
||||
let mainWindowMousePassthroughForcedByModal = false;
|
||||
let mainWindowHiddenByModal = false;
|
||||
let modalWindowPrimedForImmediateShow = false;
|
||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
|
||||
const scheduleRevealFallback = (
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
): RevealFallbackHandle =>
|
||||
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
||||
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
|
||||
(options.clearRevealFallback ?? globalThis.clearTimeout)(timeout);
|
||||
|
||||
const notifyModalStateChange = (nextState: boolean): void => {
|
||||
if (modalActive === nextState) return;
|
||||
@@ -87,9 +123,21 @@ export function createOverlayModalRuntimeService(
|
||||
};
|
||||
|
||||
const isWindowReadyForIpc = (window: BrowserWindow): boolean => {
|
||||
if (window.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
if (window.webContents.isLoading()) {
|
||||
return false;
|
||||
}
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
|
||||
};
|
||||
if (
|
||||
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
|
||||
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const currentURL = window.webContents.getURL();
|
||||
return currentURL !== '' && currentURL !== 'about:blank';
|
||||
};
|
||||
@@ -109,11 +157,17 @@ export function createOverlayModalRuntimeService(
|
||||
return;
|
||||
}
|
||||
|
||||
window.webContents.once('did-finish-load', () => {
|
||||
if (!window.isDestroyed() && !window.webContents.isLoading()) {
|
||||
sendNow(window);
|
||||
let delivered = false;
|
||||
const deliverWhenReady = (): void => {
|
||||
if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
delivered = true;
|
||||
sendNow(window);
|
||||
};
|
||||
|
||||
window.webContents.once('did-finish-load', deliverWhenReady);
|
||||
window.once('ready-to-show', deliverWhenReady);
|
||||
};
|
||||
|
||||
const showModalWindow = (
|
||||
@@ -122,6 +176,8 @@ export function createOverlayModalRuntimeService(
|
||||
passThroughMouseEvents: boolean;
|
||||
} = { passThroughMouseEvents: false },
|
||||
): void => {
|
||||
setWindowFocusable(window);
|
||||
requestOverlayApplicationFocus();
|
||||
if (!window.isVisible()) {
|
||||
window.show();
|
||||
}
|
||||
@@ -138,15 +194,14 @@ export function createOverlayModalRuntimeService(
|
||||
};
|
||||
|
||||
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
||||
setWindowFocusable(window);
|
||||
requestOverlayApplicationFocus();
|
||||
window.setIgnoreMouseEvents(false);
|
||||
elevateModalWindow(window);
|
||||
|
||||
if (window.isVisible()) {
|
||||
window.setIgnoreMouseEvents(false);
|
||||
if (!window.isFocused()) {
|
||||
window.focus();
|
||||
}
|
||||
if (!window.webContents.isFocused()) {
|
||||
window.webContents.focus();
|
||||
}
|
||||
elevateModalWindow(window);
|
||||
window.focus();
|
||||
window.webContents.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,7 +221,7 @@ export function createOverlayModalRuntimeService(
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(pendingModalWindowRevealTimeout);
|
||||
clearRevealFallback(pendingModalWindowRevealTimeout);
|
||||
pendingModalWindowRevealTimeout = null;
|
||||
pendingModalWindowReveal = null;
|
||||
};
|
||||
@@ -225,12 +280,15 @@ export function createOverlayModalRuntimeService(
|
||||
return;
|
||||
}
|
||||
|
||||
pendingModalWindowRevealTimeout = setTimeout(() => {
|
||||
pendingModalWindowRevealTimeout = scheduleRevealFallback(() => {
|
||||
const targetWindow = pendingModalWindowReveal;
|
||||
clearPendingModalWindowReveal();
|
||||
if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) {
|
||||
return;
|
||||
}
|
||||
if (!isWindowReadyForIpc(targetWindow)) {
|
||||
return;
|
||||
}
|
||||
showModalWindow(targetWindow, { passThroughMouseEvents: false });
|
||||
}, MODAL_REVEAL_FALLBACK_DELAY_MS);
|
||||
};
|
||||
@@ -256,9 +314,9 @@ export function createOverlayModalRuntimeService(
|
||||
};
|
||||
|
||||
if (restoreOnModalClose) {
|
||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
||||
const mainWindow = getTargetOverlayWindow();
|
||||
if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
|
||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
||||
sendOrQueueForWindow(mainWindow, (window) => {
|
||||
if (payload === undefined) {
|
||||
window.webContents.send(channel);
|
||||
@@ -272,15 +330,23 @@ export function createOverlayModalRuntimeService(
|
||||
const modalWindow = resolveModalWindow();
|
||||
if (!modalWindow) return false;
|
||||
|
||||
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
|
||||
deps.setModalWindowBounds(deps.getModalGeometry());
|
||||
const wasVisible = modalWindow.isVisible();
|
||||
if (!wasVisible) {
|
||||
scheduleModalWindowReveal(modalWindow);
|
||||
if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) {
|
||||
showModalWindow(modalWindow);
|
||||
} else {
|
||||
scheduleModalWindowReveal(modalWindow);
|
||||
}
|
||||
} else if (!modalWindow.isFocused()) {
|
||||
showModalWindow(modalWindow);
|
||||
}
|
||||
|
||||
sendOrQueueForWindow(modalWindow, (window) => {
|
||||
if (window.isVisible()) {
|
||||
ensureModalWindowInteractive(window);
|
||||
}
|
||||
if (payload === undefined) {
|
||||
window.webContents.send(channel);
|
||||
} else {
|
||||
@@ -320,12 +386,13 @@ export function createOverlayModalRuntimeService(
|
||||
const modalWindow = deps.getModalWindow();
|
||||
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||
clearPendingModalWindowReveal();
|
||||
notifyModalStateChange(false);
|
||||
setMainWindowMousePassthroughForModal(false);
|
||||
setMainWindowVisibilityForModal(false);
|
||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||
modalWindow.hide();
|
||||
modalWindow.destroy();
|
||||
}
|
||||
modalWindowPrimedForImmediateShow = false;
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
mainWindowHiddenByModal = false;
|
||||
notifyModalStateChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -350,14 +417,7 @@ export function createOverlayModalRuntimeService(
|
||||
}
|
||||
|
||||
if (targetWindow.isVisible()) {
|
||||
targetWindow.setIgnoreMouseEvents(false);
|
||||
elevateModalWindow(targetWindow);
|
||||
if (!targetWindow.isFocused()) {
|
||||
targetWindow.focus();
|
||||
}
|
||||
if (!targetWindow.webContents.isFocused()) {
|
||||
targetWindow.webContents.focus();
|
||||
}
|
||||
ensureModalWindowInteractive(targetWindow);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,14 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getWindowTracker: () => BaseWindowTracker | null;
|
||||
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
||||
getWindowsOverlayProcessName?: () => string | null;
|
||||
getWindowsFocusHandoffGraceActive?: () => boolean;
|
||||
getTrackerNotReadyWarningShown: () => boolean;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
|
||||
syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void;
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
@@ -36,12 +40,20 @@ export function createOverlayVisibilityRuntimeService(
|
||||
|
||||
return {
|
||||
updateVisibleOverlayVisibility(): void {
|
||||
const visibleOverlayVisible = deps.getVisibleOverlayVisible();
|
||||
const forceMousePassthrough = deps.getForceMousePassthrough();
|
||||
const windowTracker = deps.getWindowTracker();
|
||||
const mainWindow = deps.getMainWindow();
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: deps.getVisibleOverlayVisible(),
|
||||
visibleOverlayVisible,
|
||||
modalActive: deps.getModalActive(),
|
||||
forceMousePassthrough: deps.getForceMousePassthrough(),
|
||||
mainWindow: deps.getMainWindow(),
|
||||
windowTracker: deps.getWindowTracker(),
|
||||
forceMousePassthrough,
|
||||
mainWindow,
|
||||
windowTracker,
|
||||
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
||||
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
|
||||
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
deps.setTrackerNotReadyWarningShown(shown);
|
||||
@@ -49,6 +61,8 @@ export function createOverlayVisibilityRuntimeService(
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
deps.updateVisibleOverlayBounds(geometry),
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
||||
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
|
||||
deps.syncWindowsOverlayToMpvZOrder?.(window),
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') =>
|
||||
deps.syncPrimaryOverlayWindowLayer(layer),
|
||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||
|
||||
@@ -16,6 +16,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => calls.push('clear-windows-visible-overlay-poll'),
|
||||
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
|
||||
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
|
||||
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
|
||||
@@ -40,9 +41,10 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 28);
|
||||
assert.equal(calls.length, 29);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
||||
destroyMainOverlayWindow: () => void;
|
||||
destroyModalOverlayWindow: () => void;
|
||||
destroyYomitanParserWindow: () => void;
|
||||
@@ -36,6 +37,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
deps.stopSubtitleWebsocket();
|
||||
deps.stopTexthookerService();
|
||||
deps.clearWindowsVisibleOverlayForegroundPollLoop();
|
||||
deps.destroyMainOverlayWindow();
|
||||
deps.destroyModalOverlayWindow();
|
||||
deps.destroyYomitanParserWindow();
|
||||
|
||||
@@ -18,6 +18,8 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
|
||||
getMainOverlayWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||
@@ -85,6 +87,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
assert.equal(reconnectTimer, null);
|
||||
assert.equal(immersionTracker, null);
|
||||
});
|
||||
@@ -99,6 +102,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
getMainOverlayWindow: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
||||
getMainOverlayWindow: () => DestroyableWindow | null;
|
||||
clearMainOverlayWindow: () => void;
|
||||
getModalOverlayWindow: () => DestroyableWindow | null;
|
||||
@@ -64,6 +65,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
||||
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
|
||||
destroyMainOverlayWindow: () => {
|
||||
const window = deps.getMainOverlayWindow();
|
||||
if (!window) return;
|
||||
|
||||
@@ -42,6 +42,7 @@ test('build cli command context deps maps handlers and values', () => {
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('mark');
|
||||
},
|
||||
dispatchSessionAction: async () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => calls.push('clear-token'),
|
||||
openAnilistSetup: () => calls.push('anilist'),
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
|
||||
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
|
||||
clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken'];
|
||||
openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup'];
|
||||
@@ -77,6 +78,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
||||
triggerFieldGrouping: deps.triggerFieldGrouping,
|
||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
|
||||
dispatchSessionAction: deps.dispatchSessionAction,
|
||||
getAnilistStatus: deps.getAnilistStatus,
|
||||
clearAnilistToken: deps.clearAnilistToken,
|
||||
openAnilistSetup: deps.openAnilistSetup,
|
||||
|
||||
@@ -37,6 +37,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
dispatchSessionAction: async () => {},
|
||||
getAnilistStatus: () => ({
|
||||
tokenStatus: 'resolved',
|
||||
tokenSource: 'literal',
|
||||
|
||||
@@ -53,6 +53,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('mark-audio');
|
||||
},
|
||||
dispatchSessionAction: async () => {},
|
||||
|
||||
getAnilistStatus: () => ({
|
||||
tokenStatus: 'resolved',
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction'];
|
||||
|
||||
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
|
||||
clearAnilistToken: () => void;
|
||||
@@ -103,6 +104,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
||||
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
|
||||
dispatchSessionAction: (request) => deps.dispatchSessionAction(request),
|
||||
getAnilistStatus: () => deps.getAnilistStatus(),
|
||||
clearAnilistToken: () => deps.clearAnilistToken(),
|
||||
openAnilistSetup: () => deps.openAnilistSetupWindow(),
|
||||
|
||||
@@ -36,6 +36,7 @@ function createDeps() {
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
dispatchSessionAction: async () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
|
||||
@@ -33,6 +33,7 @@ export type CliCommandContextFactoryDeps = {
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
dispatchSessionAction: CliCommandRuntimeServiceContext['dispatchSessionAction'];
|
||||
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
|
||||
clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken'];
|
||||
openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup'];
|
||||
@@ -89,6 +90,7 @@ export function createCliCommandContext(
|
||||
triggerFieldGrouping: deps.triggerFieldGrouping,
|
||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||
markLastCardAsAudioCard: deps.markLastCardAsAudioCard,
|
||||
dispatchSessionAction: deps.dispatchSessionAction,
|
||||
getAnilistStatus: deps.getAnilistStatus,
|
||||
clearAnilistToken: deps.clearAnilistToken,
|
||||
openAnilistSetup: deps.openAnilistSetup,
|
||||
|
||||
@@ -30,6 +30,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
dispatchSessionAction: async () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetupWindow: () => {},
|
||||
|
||||
@@ -53,7 +53,9 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
saveSubtitlePosition: () => {},
|
||||
getMecabTokenizer: () => null,
|
||||
getKeybindings: () => [],
|
||||
getSessionBindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}) as never,
|
||||
dispatchSessionAction: async () => {},
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getMarkWatchedKey: () => 'KeyW',
|
||||
getControllerConfig: () => ({}) as never,
|
||||
|
||||
@@ -21,6 +21,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
getMainOverlayWindow: () => null,
|
||||
clearMainOverlayWindow: () => {},
|
||||
getModalOverlayWindow: () => null,
|
||||
|
||||
@@ -11,9 +11,14 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
const calls: string[] = [];
|
||||
const ankiPatches: Array<{ enabled: boolean }> = [];
|
||||
const sessionBindingWarnings: string[][] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => calls.push('set:keybindings'),
|
||||
setSessionBindings: (_sessionBindings, warnings) => {
|
||||
calls.push('set:session-bindings');
|
||||
sessionBindingWarnings.push(warnings.map((warning) => warning.message));
|
||||
},
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
||||
setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
@@ -37,11 +42,18 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => {
|
||||
);
|
||||
|
||||
assert.ok(calls.includes('set:keybindings'));
|
||||
assert.ok(calls.includes('set:session-bindings'));
|
||||
assert.ok(calls.includes('refresh:shortcuts'));
|
||||
assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`));
|
||||
assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:')));
|
||||
assert.ok(calls.includes('broadcast:config:hot-reload:object'));
|
||||
assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]);
|
||||
assert.equal(sessionBindingWarnings.length, 1);
|
||||
assert.ok(
|
||||
sessionBindingWarnings[0]?.some((message) =>
|
||||
message.includes('Rename shortcuts.toggleVisibleOverlayGlobal'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
|
||||
@@ -50,6 +62,7 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => calls.push('set:keybindings'),
|
||||
setSessionBindings: () => calls.push('set:session-bindings'),
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'),
|
||||
setSecondarySubMode: () => calls.push('set:secondary'),
|
||||
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
||||
@@ -64,7 +77,35 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['set:keybindings']);
|
||||
assert.deepEqual(calls, ['set:keybindings', 'set:session-bindings']);
|
||||
});
|
||||
|
||||
test('createConfigHotReloadAppliedHandler forwards compiled session-binding warnings', () => {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
config.shortcuts.openSessionHelp = 'Ctrl+?';
|
||||
const warnings: string[][] = [];
|
||||
|
||||
const applyHotReload = createConfigHotReloadAppliedHandler({
|
||||
setKeybindings: () => {},
|
||||
setSessionBindings: (_sessionBindings, sessionBindingWarnings) => {
|
||||
warnings.push(sessionBindingWarnings.map((warning) => warning.message));
|
||||
},
|
||||
refreshGlobalAndOverlayShortcuts: () => {},
|
||||
setSecondarySubMode: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
applyAnkiRuntimeConfigPatch: () => {},
|
||||
});
|
||||
|
||||
applyHotReload(
|
||||
{
|
||||
hotReloadFields: ['shortcuts'],
|
||||
restartRequiredFields: [],
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.ok(warnings[0]?.some((message) => message.includes('Unsupported accelerator key token')));
|
||||
});
|
||||
|
||||
test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload';
|
||||
import { compileSessionBindings } from '../../core/services/session-bindings';
|
||||
import { resolveKeybindings } from '../../core/utils/keybindings';
|
||||
import { DEFAULT_KEYBINDINGS } from '../../config';
|
||||
import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
|
||||
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
|
||||
|
||||
type ConfigHotReloadAppliedDeps = {
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
||||
setSessionBindings: (
|
||||
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
|
||||
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
|
||||
) => void;
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
@@ -33,8 +39,23 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) {
|
||||
}
|
||||
|
||||
export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload {
|
||||
const keybindings = resolveKeybindings(config, DEFAULT_KEYBINDINGS);
|
||||
const { bindings: sessionBindings, warnings: sessionBindingWarnings } = compileSessionBindings({
|
||||
keybindings,
|
||||
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
|
||||
statsToggleKey: config.stats.toggleKey,
|
||||
platform:
|
||||
process.platform === 'darwin'
|
||||
? 'darwin'
|
||||
: process.platform === 'win32'
|
||||
? 'win32'
|
||||
: 'linux',
|
||||
rawConfig: config,
|
||||
});
|
||||
return {
|
||||
keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS),
|
||||
keybindings,
|
||||
sessionBindings,
|
||||
sessionBindingWarnings,
|
||||
subtitleStyle: resolveSubtitleStyleForRenderer(config),
|
||||
subtitleSidebar: config.subtitleSidebar,
|
||||
secondarySubMode: config.secondarySub.defaultMode,
|
||||
@@ -45,6 +66,7 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
||||
return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => {
|
||||
const payload = buildConfigHotReloadPayload(config);
|
||||
deps.setKeybindings(payload.keybindings);
|
||||
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
|
||||
|
||||
if (diff.hotReloadFields.includes('shortcuts')) {
|
||||
deps.refreshGlobalAndOverlayShortcuts();
|
||||
|
||||
@@ -86,26 +86,35 @@ test('config hot reload message main deps builder maps notifications', () => {
|
||||
|
||||
test('config hot reload applied main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildConfigHotReloadAppliedMainDepsHandler({
|
||||
const warningCounts: number[] = [];
|
||||
const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({
|
||||
setKeybindings: () => calls.push('keybindings'),
|
||||
setSessionBindings: (_sessionBindings, warnings) => {
|
||||
calls.push('session-bindings');
|
||||
warningCounts.push(warnings.length);
|
||||
},
|
||||
refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'),
|
||||
setSecondarySubMode: () => calls.push('set-secondary'),
|
||||
broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`),
|
||||
applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'),
|
||||
})();
|
||||
});
|
||||
|
||||
const deps = buildDeps();
|
||||
deps.setKeybindings([]);
|
||||
deps.setSessionBindings([], []);
|
||||
deps.refreshGlobalAndOverlayShortcuts();
|
||||
deps.setSecondarySubMode('hover');
|
||||
deps.broadcastToOverlayWindows('config:hot-reload', {});
|
||||
deps.applyAnkiRuntimeConfigPatch({ ai: true });
|
||||
assert.deepEqual(calls, [
|
||||
'keybindings',
|
||||
'session-bindings',
|
||||
'refresh-shortcuts',
|
||||
'set-secondary',
|
||||
'broadcast:config:hot-reload',
|
||||
'apply-anki',
|
||||
]);
|
||||
assert.deepEqual(warningCounts, [0]);
|
||||
});
|
||||
|
||||
test('config hot reload runtime main deps builder maps runtime callbacks', () => {
|
||||
|
||||
@@ -62,6 +62,10 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
|
||||
|
||||
export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
|
||||
setSessionBindings: (
|
||||
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
|
||||
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
|
||||
) => void;
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
@@ -72,6 +76,10 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
return () => ({
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
|
||||
deps.setKeybindings(keybindings),
|
||||
setSessionBindings: (
|
||||
sessionBindings: ConfigHotReloadPayload['sessionBindings'],
|
||||
sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'],
|
||||
) => deps.setSessionBindings(sessionBindings, sessionBindingWarnings),
|
||||
refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(),
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode),
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) =>
|
||||
|
||||
48
src/main/runtime/controller-debug-open.ts
Normal file
48
src/main/runtime/controller-debug-open.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const CONTROLLER_DEBUG_MODAL: OverlayHostedModal = 'controller-debug';
|
||||
const CONTROLLER_DEBUG_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openControllerDebugModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: CONTROLLER_DEBUG_MODAL,
|
||||
timeoutMs: CONTROLLER_DEBUG_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Controller debug modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.controllerDebugOpen,
|
||||
modal: CONTROLLER_DEBUG_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
48
src/main/runtime/controller-select-open.ts
Normal file
48
src/main/runtime/controller-select-open.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const CONTROLLER_SELECT_MODAL: OverlayHostedModal = 'controller-select';
|
||||
const CONTROLLER_SELECT_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openControllerSelectModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: CONTROLLER_SELECT_MODAL,
|
||||
timeoutMs: CONTROLLER_SELECT_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Controller select modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.controllerSelectOpen,
|
||||
modal: CONTROLLER_SELECT_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,21 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
triggerFieldGrouping: false,
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
toggleSubtitleSidebar: false,
|
||||
openRuntimeOptions: false,
|
||||
openSessionHelp: false,
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
shiftSubDelayNextLine: false,
|
||||
cycleRuntimeOptionId: undefined,
|
||||
cycleRuntimeOptionDirection: undefined,
|
||||
anilistStatus: false,
|
||||
anilistLogout: false,
|
||||
anilistSetup: false,
|
||||
@@ -79,6 +93,41 @@ test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
|
||||
});
|
||||
|
||||
test('shouldAutoOpenFirstRunSetup treats numeric startup counts as explicit commands', () => {
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ start: true, copySubtitleCount: 2 })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, mineSentenceCount: 1 })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldAutoOpenFirstRunSetup treats session and stats startup commands as explicit commands', () => {
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ start: true, toggleSubtitleSidebar: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, openSessionHelp: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ start: true, openControllerSelect: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, openControllerDebug: true })),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, stats: true })), false);
|
||||
assert.equal(
|
||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinSubtitleUrlsOnly: true })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('setup service auto-completes legacy installs with config and dictionaries', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
|
||||
@@ -68,26 +68,43 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
args.hideVisibleOverlay ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
args.copySubtitleCount !== undefined ||
|
||||
args.mineSentence ||
|
||||
args.mineSentenceMultiple ||
|
||||
args.mineSentenceCount !== undefined ||
|
||||
args.updateLastCardFromClipboard ||
|
||||
args.refreshKnownWords ||
|
||||
args.toggleSecondarySub ||
|
||||
args.triggerFieldGrouping ||
|
||||
args.triggerSubsync ||
|
||||
args.markAudioCard ||
|
||||
args.toggleStatsOverlay ||
|
||||
args.toggleSubtitleSidebar ||
|
||||
args.openRuntimeOptions ||
|
||||
args.openSessionHelp ||
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
args.playNextSubtitle ||
|
||||
args.shiftSubDelayPrevLine ||
|
||||
args.shiftSubDelayNextLine ||
|
||||
args.cycleRuntimeOptionId !== undefined ||
|
||||
args.anilistStatus ||
|
||||
args.anilistLogout ||
|
||||
args.anilistSetup ||
|
||||
args.anilistRetryQueue ||
|
||||
args.dictionary ||
|
||||
args.stats ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinLogin ||
|
||||
args.jellyfinLogout ||
|
||||
args.jellyfinLibraries ||
|
||||
args.jellyfinItems ||
|
||||
args.jellyfinSubtitles ||
|
||||
args.jellyfinSubtitleUrlsOnly ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth ||
|
||||
|
||||
@@ -18,6 +18,10 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
markAudioCard: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
markAudioCard: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +161,44 @@ test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv
|
||||
assert.ok(calls.includes('info:Auto-connecting MPV client for immersion tracking'));
|
||||
});
|
||||
|
||||
test('createImmersionTrackerStartupHandler keeps tracker startup alive when mpv auto-connect throws', () => {
|
||||
const calls: string[] = [];
|
||||
const trackerInstance = { kind: 'tracker' };
|
||||
let assignedTracker: unknown = null;
|
||||
const handler = createImmersionTrackerStartupHandler({
|
||||
getResolvedConfig: () => makeConfig(),
|
||||
getConfiguredDbPath: () => '/tmp/subminer.db',
|
||||
createTrackerService: () => trackerInstance,
|
||||
setTracker: (nextTracker) => {
|
||||
assignedTracker = nextTracker;
|
||||
},
|
||||
getMpvClient: () => ({
|
||||
connected: false,
|
||||
connect: () => {
|
||||
throw new Error('socket not ready');
|
||||
},
|
||||
}),
|
||||
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
logWarn: (message, details) => calls.push(`warn:${message}:${(details as Error).message}`),
|
||||
});
|
||||
|
||||
handler();
|
||||
|
||||
assert.equal(assignedTracker, trackerInstance);
|
||||
assert.ok(calls.includes('seedTracker'));
|
||||
assert.ok(
|
||||
calls.includes(
|
||||
'warn:MPV auto-connect failed during immersion tracker startup; continuing.:socket not ready',
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
calls.some((entry) => entry.startsWith('warn:Immersion tracker startup failed; disabling tracking.')),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('createImmersionTrackerStartupHandler disables tracker on failure', () => {
|
||||
const calls: string[] = [];
|
||||
let assignedTracker: unknown = 'initial';
|
||||
|
||||
@@ -102,7 +102,11 @@ export function createImmersionTrackerStartupHandler(
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if ((deps.shouldAutoConnectMpv?.() ?? true) && mpvClient && !mpvClient.connected) {
|
||||
deps.logInfo('Auto-connecting MPV client for immersion tracking');
|
||||
mpvClient.connect();
|
||||
try {
|
||||
mpvClient.connect();
|
||||
} catch (error) {
|
||||
deps.logWarn('MPV auto-connect failed during immersion tracker startup; continuing.', error);
|
||||
}
|
||||
}
|
||||
deps.seedTrackerFromCurrentMedia();
|
||||
} catch (error) {
|
||||
|
||||
48
src/main/runtime/jimaku-open.ts
Normal file
48
src/main/runtime/jimaku-open.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const JIMAKU_MODAL: OverlayHostedModal = 'jimaku';
|
||||
const JIMAKU_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openJimakuModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: JIMAKU_MODAL,
|
||||
timeoutMs: JIMAKU_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Jimaku modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.jimakuOpen,
|
||||
modal: JIMAKU_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
66
src/main/runtime/overlay-hosted-modal-open.test.ts
Normal file
66
src/main/runtime/overlay-hosted-modal-open.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { openOverlayHostedModal } from './overlay-hosted-modal-open';
|
||||
|
||||
test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const opened = openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
calls.push('ensureOverlayStartupPrereqs');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
|
||||
},
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
|
||||
calls.push(`send:${channel}`);
|
||||
assert.equal(payload, undefined);
|
||||
assert.deepEqual(runtimeOptions, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
preferModalWindow: undefined,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: 'runtime-options:open',
|
||||
modal: 'runtime-options',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(calls, [
|
||||
'ensureOverlayStartupPrereqs',
|
||||
'ensureOverlayWindowsReadyForVisibilityActions',
|
||||
'send:runtime-options:open',
|
||||
]);
|
||||
});
|
||||
|
||||
test('openOverlayHostedModal forwards payload and modal-window preference', () => {
|
||||
const payload = { sessionId: 'yt-1' };
|
||||
|
||||
const opened = openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||
sendToActiveOverlayWindow: (channel, forwardedPayload, runtimeOptions) => {
|
||||
assert.equal(channel, 'youtube:picker-open');
|
||||
assert.deepEqual(forwardedPayload, payload);
|
||||
assert.deepEqual(runtimeOptions, {
|
||||
restoreOnModalClose: 'youtube-track-picker',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
channel: 'youtube:picker-open',
|
||||
modal: 'youtube-track-picker',
|
||||
payload,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(opened, false);
|
||||
});
|
||||
57
src/main/runtime/overlay-hosted-modal-open.ts
Normal file
57
src/main/runtime/overlay-hosted-modal-open.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
|
||||
export function openOverlayHostedModal(
|
||||
deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
},
|
||||
input: {
|
||||
channel: string;
|
||||
modal: OverlayHostedModal;
|
||||
payload?: unknown;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
): boolean {
|
||||
deps.ensureOverlayStartupPrereqs();
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions();
|
||||
return deps.sendToActiveOverlayWindow(input.channel, input.payload, {
|
||||
restoreOnModalClose: input.modal,
|
||||
preferModalWindow: input.preferModalWindow,
|
||||
});
|
||||
}
|
||||
|
||||
export async function retryOverlayModalOpen(
|
||||
deps: {
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
},
|
||||
input: {
|
||||
modal: OverlayHostedModal;
|
||||
timeoutMs: number;
|
||||
retryWarning: string;
|
||||
sendOpen: () => boolean;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
if (!input.sendOpen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await deps.waitForModalOpen(input.modal, input.timeoutMs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
deps.logWarn(input.retryWarning);
|
||||
if (!input.sendOpen()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await deps.waitForModalOpen(input.modal, input.timeoutMs);
|
||||
}
|
||||
@@ -23,6 +23,9 @@ function createModalWindow() {
|
||||
setIgnoreMouseEvents: (ignore: boolean) => {
|
||||
calls.push(`ignore:${ignore}`);
|
||||
},
|
||||
setFocusable: (focusable: boolean) => {
|
||||
calls.push(`focusable:${focusable}`);
|
||||
},
|
||||
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
|
||||
calls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
|
||||
},
|
||||
@@ -58,6 +61,7 @@ test('overlay modal input state activates modal window interactivity and syncs d
|
||||
|
||||
assert.equal(state.getModalInputExclusive(), true);
|
||||
assert.deepEqual(modalWindow.calls, [
|
||||
'focusable:true',
|
||||
'ignore:false',
|
||||
'top:true:screen-saver:1',
|
||||
'focus',
|
||||
@@ -66,6 +70,25 @@ test('overlay modal input state activates modal window interactivity and syncs d
|
||||
assert.deepEqual(calls, ['shortcuts:true', 'visibility']);
|
||||
});
|
||||
|
||||
test('overlay modal input state restores main window focus on deactivation', () => {
|
||||
const modalWindow = createModalWindow();
|
||||
const calls: string[] = [];
|
||||
const state = createOverlayModalInputState({
|
||||
getModalWindow: () => modalWindow as never,
|
||||
syncOverlayShortcutsForModal: () => {},
|
||||
syncOverlayVisibilityForModal: () => {},
|
||||
restoreMainWindowFocus: () => {
|
||||
calls.push('restore-focus');
|
||||
},
|
||||
});
|
||||
|
||||
state.handleModalInputStateChange(true);
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
state.handleModalInputStateChange(false);
|
||||
assert.deepEqual(calls, ['restore-focus']);
|
||||
});
|
||||
|
||||
test('overlay modal input state is idempotent for unchanged state', () => {
|
||||
const calls: string[] = [];
|
||||
const state = createOverlayModalInputState({
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
function requestOverlayApplicationFocus(): void {
|
||||
try {
|
||||
const electron = require('electron') as {
|
||||
app?: {
|
||||
focus?: (options?: { steal?: boolean }) => void;
|
||||
};
|
||||
};
|
||||
electron.app?.focus?.({ steal: true });
|
||||
} catch {
|
||||
// Ignore focus-steal failures in non-Electron test environments.
|
||||
}
|
||||
}
|
||||
|
||||
function setWindowFocusable(window: BrowserWindow): void {
|
||||
const maybeFocusableWindow = window as BrowserWindow & {
|
||||
setFocusable?: (focusable: boolean) => void;
|
||||
};
|
||||
maybeFocusableWindow.setFocusable?.(true);
|
||||
}
|
||||
|
||||
export type OverlayModalInputStateDeps = {
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
restoreMainWindowFocus?: () => void;
|
||||
};
|
||||
|
||||
export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
@@ -18,6 +39,8 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
if (isActive) {
|
||||
const modalWindow = deps.getModalWindow();
|
||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||
setWindowFocusable(modalWindow);
|
||||
requestOverlayApplicationFocus();
|
||||
modalWindow.setIgnoreMouseEvents(false);
|
||||
modalWindow.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
modalWindow.focus();
|
||||
@@ -29,6 +52,9 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
|
||||
deps.syncOverlayShortcutsForModal(isActive);
|
||||
deps.syncOverlayVisibilityForModal();
|
||||
if (!isActive) {
|
||||
deps.restoreMainWindowFocus?.();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -31,6 +31,8 @@ type InitializeOverlayRuntimeCore = (options: {
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
}) => void;
|
||||
|
||||
export function createInitializeOverlayRuntimeHandler(deps: {
|
||||
|
||||
@@ -23,6 +23,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
||||
},
|
||||
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||
},
|
||||
@@ -53,6 +54,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
deps.registerGlobalShortcuts();
|
||||
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||
deps.updateVisibleOverlayVisibility();
|
||||
deps.refreshCurrentSubtitle?.();
|
||||
deps.syncOverlayShortcuts();
|
||||
deps.showDesktopNotification('title', {});
|
||||
|
||||
@@ -68,6 +70,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
|
||||
'register-shortcuts',
|
||||
'visible-bounds',
|
||||
'update-visible',
|
||||
'refresh-subtitle',
|
||||
'sync-shortcuts',
|
||||
'notify',
|
||||
]);
|
||||
|
||||
@@ -21,6 +21,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
};
|
||||
refreshCurrentSubtitle?: () => void;
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => void;
|
||||
};
|
||||
@@ -39,6 +40,8 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
}) {
|
||||
return (): OverlayRuntimeOptionsMainDeps => ({
|
||||
getBackendOverride: () => deps.appState.backendOverride,
|
||||
@@ -53,6 +56,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
|
||||
updateVisibleOverlayVisibility: () =>
|
||||
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
|
||||
getOverlayWindows: () => deps.getOverlayWindows(),
|
||||
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
setWindowTracker: (tracker) => {
|
||||
@@ -71,5 +75,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
|
||||
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
|
||||
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
|
||||
bindOverlayOwner: deps.bindOverlayOwner,
|
||||
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'),
|
||||
isVisibleOverlayVisible: () => true,
|
||||
updateVisibleOverlayVisibility: () => calls.push('update-visible'),
|
||||
refreshCurrentSubtitle: () => calls.push('refresh-subtitle'),
|
||||
getOverlayWindows: () => [],
|
||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||
setWindowTracker: () => calls.push('set-tracker'),
|
||||
@@ -41,6 +42,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
options.registerGlobalShortcuts();
|
||||
options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||
options.updateVisibleOverlayVisibility();
|
||||
options.refreshCurrentSubtitle?.();
|
||||
options.syncOverlayShortcuts();
|
||||
options.setWindowTracker(null);
|
||||
options.setAnkiIntegration(null);
|
||||
@@ -51,6 +53,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
|
||||
'register-shortcuts',
|
||||
'update-visible-bounds',
|
||||
'update-visible',
|
||||
'refresh-subtitle',
|
||||
'sync-shortcuts',
|
||||
'set-tracker',
|
||||
'set-anki',
|
||||
|
||||
@@ -14,6 +14,7 @@ type OverlayRuntimeOptions = {
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
refreshCurrentSubtitle?: () => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
@@ -35,6 +36,8 @@ type OverlayRuntimeOptions = {
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
};
|
||||
|
||||
export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
@@ -44,6 +47,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
refreshCurrentSubtitle?: () => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
syncOverlayShortcuts: () => void;
|
||||
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||
@@ -65,6 +69,8 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
}) {
|
||||
return (): OverlayRuntimeOptions => ({
|
||||
backendOverride: deps.getBackendOverride(),
|
||||
@@ -73,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds,
|
||||
isVisibleOverlayVisible: deps.isVisibleOverlayVisible,
|
||||
updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility,
|
||||
refreshCurrentSubtitle: deps.refreshCurrentSubtitle,
|
||||
getOverlayWindows: deps.getOverlayWindows,
|
||||
syncOverlayShortcuts: deps.syncOverlayShortcuts,
|
||||
setWindowTracker: deps.setWindowTracker,
|
||||
@@ -87,5 +94,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
|
||||
createFieldGroupingCallback: deps.createFieldGroupingCallback,
|
||||
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
|
||||
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
|
||||
bindOverlayOwner: deps.bindOverlayOwner,
|
||||
releaseOverlayOwner: deps.releaseOverlayOwner,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getForceMousePassthrough: () => true,
|
||||
getWindowTracker: () => tracker,
|
||||
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
||||
getWindowsOverlayProcessName: () => 'subminer',
|
||||
getWindowsFocusHandoffGraceActive: () => true,
|
||||
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
|
||||
setTrackerNotReadyWarningShown: (shown) => {
|
||||
trackerNotReadyWarningShown = shown;
|
||||
@@ -23,6 +26,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
},
|
||||
updateVisibleOverlayBounds: () => calls.push('visible-bounds'),
|
||||
ensureOverlayWindowLevel: () => calls.push('ensure-level'),
|
||||
syncWindowsOverlayToMpvZOrder: () => calls.push('sync-windows-z-order'),
|
||||
syncPrimaryOverlayWindowLayer: (layer) => calls.push(`primary-layer:${layer}`),
|
||||
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
|
||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||
@@ -36,10 +40,14 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
assert.equal(deps.getModalActive(), true);
|
||||
assert.equal(deps.getVisibleOverlayVisible(), true);
|
||||
assert.equal(deps.getForceMousePassthrough(), true);
|
||||
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
|
||||
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
|
||||
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
|
||||
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
|
||||
deps.setTrackerNotReadyWarningShown(true);
|
||||
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||
deps.ensureOverlayWindowLevel(mainWindow);
|
||||
deps.syncWindowsOverlayToMpvZOrder?.(mainWindow);
|
||||
deps.syncPrimaryOverlayWindowLayer('visible');
|
||||
deps.enforceOverlayLayerOrder();
|
||||
deps.syncOverlayShortcuts();
|
||||
@@ -52,6 +60,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
'tracker-warning:true',
|
||||
'visible-bounds',
|
||||
'ensure-level',
|
||||
'sync-windows-z-order',
|
||||
'primary-layer:visible',
|
||||
'enforce-order',
|
||||
'sync-shortcuts',
|
||||
|
||||
@@ -11,11 +11,17 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||
getWindowTracker: () => deps.getWindowTracker(),
|
||||
getLastKnownWindowsForegroundProcessName: () =>
|
||||
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
|
||||
getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null,
|
||||
getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||
deps.updateVisibleOverlayBounds(geometry),
|
||||
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),
|
||||
syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) =>
|
||||
deps.syncWindowsOverlayToMpvZOrder?.(window),
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer),
|
||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||
|
||||
@@ -11,6 +11,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
@@ -22,6 +24,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
|
||||
getYomitanSession?: () => Session | null;
|
||||
}) {
|
||||
@@ -34,6 +38,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
|
||||
isOverlayVisible: deps.isOverlayVisible,
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
forwardTabToMpv: deps.forwardTabToMpv,
|
||||
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||
onWindowContentReady: deps.onWindowContentReady,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
@@ -24,6 +26,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onVisibleWindowBlurred?: () => void;
|
||||
onWindowContentReady?: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
getYomitanSession?: () => Session | null;
|
||||
}) {
|
||||
@@ -36,6 +40,8 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
|
||||
isOverlayVisible: deps.isOverlayVisible,
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
forwardTabToMpv: deps.forwardTabToMpv,
|
||||
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||
onWindowContentReady: deps.onWindowContentReady,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
yomitanSession: deps.getYomitanSession?.() ?? null,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
|
||||
) {
|
||||
return (): UpdateVisibleOverlayBoundsMainDeps => ({
|
||||
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
|
||||
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,22 @@ test('visible bounds handler writes visible layer geometry', () => {
|
||||
assert.deepEqual(calls, [geometry]);
|
||||
});
|
||||
|
||||
test('visible bounds handler runs follow-up callback after applying geometry', () => {
|
||||
const calls: string[] = [];
|
||||
const geometry = { x: 0, y: 0, width: 100, height: 50 };
|
||||
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
|
||||
setOverlayWindowBounds: () => calls.push('set-bounds'),
|
||||
afterSetOverlayWindowBounds: (nextGeometry) => {
|
||||
assert.deepEqual(nextGeometry, geometry);
|
||||
calls.push('after-bounds');
|
||||
},
|
||||
});
|
||||
|
||||
handleVisible(geometry);
|
||||
|
||||
assert.deepEqual(calls, ['set-bounds', 'after-bounds']);
|
||||
});
|
||||
|
||||
test('ensure overlay window level handler delegates to core', () => {
|
||||
const calls: string[] = [];
|
||||
const ensureLevel = createEnsureOverlayWindowLevelHandler({
|
||||
|
||||
@@ -2,9 +2,11 @@ import type { WindowGeometry } from '../../types';
|
||||
|
||||
export function createUpdateVisibleOverlayBoundsHandler(deps: {
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
afterSetOverlayWindowBounds?: (geometry: WindowGeometry) => void;
|
||||
}) {
|
||||
return (geometry: WindowGeometry): void => {
|
||||
deps.setOverlayWindowBounds(geometry);
|
||||
deps.afterSetOverlayWindowBounds?.(geometry);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ import test from 'node:test';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openPlaylistBrowser } from './playlist-browser-open';
|
||||
|
||||
test('playlist browser open bootstraps overlay runtime before dispatching the modal event', () => {
|
||||
test('playlist browser open bootstraps overlay runtime and sends modal event with preferModalWindow', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const opened = openPlaylistBrowser({
|
||||
const opened = await openPlaylistBrowser({
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
calls.push('prereqs');
|
||||
},
|
||||
@@ -18,11 +18,31 @@ test('playlist browser open bootstraps overlay runtime before dispatching the mo
|
||||
assert.equal(payload, undefined);
|
||||
assert.deepEqual(runtimeOptions, {
|
||||
restoreOnModalClose: 'playlist-browser',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async () => true,
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(calls, ['prereqs', 'windows', `send:${IPC_CHANNELS.event.playlistBrowserOpen}`]);
|
||||
});
|
||||
|
||||
test('playlist browser open retries after first attempt timeout', async () => {
|
||||
let attempt = 0;
|
||||
const opened = await openPlaylistBrowser({
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||
sendToActiveOverlayWindow: () => true,
|
||||
waitForModalOpen: async () => {
|
||||
attempt += 1;
|
||||
return attempt >= 2;
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.equal(attempt, 2);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const PLAYLIST_BROWSER_MODAL: OverlayHostedModal = 'playlist-browser';
|
||||
const PLAYLIST_BROWSER_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export function openPlaylistBrowser(deps: {
|
||||
export async function openPlaylistBrowser(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
@@ -14,10 +16,33 @@ export function openPlaylistBrowser(deps: {
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
}): boolean {
|
||||
deps.ensureOverlayStartupPrereqs();
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions();
|
||||
return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.playlistBrowserOpen, undefined, {
|
||||
restoreOnModalClose: PLAYLIST_BROWSER_MODAL,
|
||||
});
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: PLAYLIST_BROWSER_MODAL,
|
||||
timeoutMs: PLAYLIST_BROWSER_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Playlist browser modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.playlistBrowserOpen,
|
||||
modal: PLAYLIST_BROWSER_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
99
src/main/runtime/runtime-options-open.test.ts
Normal file
99
src/main/runtime/runtime-options-open.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { openRuntimeOptionsModal } from './runtime-options-open';
|
||||
|
||||
test('runtime options open prefers dedicated modal window on first attempt', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const opened = await openRuntimeOptionsModal({
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
calls.push('ensure-startup');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('ensure-windows');
|
||||
},
|
||||
sendToActiveOverlayWindow: (channel, payload, options) => {
|
||||
calls.push(`send:${channel}`);
|
||||
assert.equal(payload, undefined);
|
||||
assert.deepEqual(options, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async (modal, timeoutMs) => {
|
||||
assert.equal(modal, 'runtime-options');
|
||||
assert.equal(timeoutMs, 1500);
|
||||
return true;
|
||||
},
|
||||
logWarn: () => {
|
||||
throw new Error('should not warn on first-attempt success');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(calls, ['ensure-startup', 'ensure-windows', 'send:runtime-options:open']);
|
||||
});
|
||||
|
||||
test('runtime options open retries after an open timeout', async () => {
|
||||
const calls: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let waitCalls = 0;
|
||||
|
||||
const opened = await openRuntimeOptionsModal({
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
calls.push('ensure-startup');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('ensure-windows');
|
||||
},
|
||||
sendToActiveOverlayWindow: (channel, payload, options) => {
|
||||
calls.push(`send:${channel}`);
|
||||
assert.equal(payload, undefined);
|
||||
assert.deepEqual(options, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async (modal, timeoutMs) => {
|
||||
assert.equal(modal, 'runtime-options');
|
||||
assert.equal(timeoutMs, 1500);
|
||||
waitCalls += 1;
|
||||
return waitCalls === 2;
|
||||
},
|
||||
logWarn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(calls, [
|
||||
'ensure-startup',
|
||||
'ensure-windows',
|
||||
'send:runtime-options:open',
|
||||
'ensure-startup',
|
||||
'ensure-windows',
|
||||
'send:runtime-options:open',
|
||||
]);
|
||||
assert.deepEqual(warnings, [
|
||||
'Runtime options modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('runtime options open fails when no overlay window can be targeted', async () => {
|
||||
let waitCalls = 0;
|
||||
const opened = await openRuntimeOptionsModal({
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||
sendToActiveOverlayWindow: () => false,
|
||||
waitForModalOpen: async () => {
|
||||
waitCalls += 1;
|
||||
return true;
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(opened, false);
|
||||
assert.equal(waitCalls, 0);
|
||||
});
|
||||
47
src/main/runtime/runtime-options-open.ts
Normal file
47
src/main/runtime/runtime-options-open.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const RUNTIME_OPTIONS_MODAL: OverlayHostedModal = 'runtime-options';
|
||||
const RUNTIME_OPTIONS_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openRuntimeOptionsModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: RUNTIME_OPTIONS_MODAL,
|
||||
timeoutMs: RUNTIME_OPTIONS_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Runtime options modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: 'runtime-options:open',
|
||||
modal: RUNTIME_OPTIONS_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
17
src/main/runtime/session-bindings-artifact.ts
Normal file
17
src/main/runtime/session-bindings-artifact.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { PluginSessionBindingsArtifact } from '../../types';
|
||||
|
||||
export function getSessionBindingsArtifactPath(configDir: string): string {
|
||||
return path.join(configDir, 'session-bindings.json');
|
||||
}
|
||||
|
||||
export function writeSessionBindingsArtifact(
|
||||
configDir: string,
|
||||
artifact: PluginSessionBindingsArtifact,
|
||||
): string {
|
||||
const artifactPath = getSessionBindingsArtifactPath(configDir);
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
|
||||
return artifactPath;
|
||||
}
|
||||
48
src/main/runtime/session-help-open.ts
Normal file
48
src/main/runtime/session-help-open.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const SESSION_HELP_MODAL: OverlayHostedModal = 'session-help';
|
||||
const SESSION_HELP_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openSessionHelpModal(deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: SESSION_HELP_MODAL,
|
||||
timeoutMs: SESSION_HELP_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Session help modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.sessionHelpOpen,
|
||||
modal: SESSION_HELP_MODAL,
|
||||
preferModalWindow: true,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { YoutubePickerOpenPayload } from '../../types';
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const YOUTUBE_PICKER_MODAL: OverlayHostedModal = 'youtube-track-picker';
|
||||
const YOUTUBE_PICKER_OPEN_TIMEOUT_MS = 1500;
|
||||
@@ -19,24 +20,21 @@ export async function openYoutubeTrackPicker(
|
||||
},
|
||||
payload: YoutubePickerOpenPayload,
|
||||
): Promise<boolean> {
|
||||
const sendPickerOpen = (): boolean =>
|
||||
deps.sendToActiveOverlayWindow('youtube:picker-open', payload, {
|
||||
restoreOnModalClose: YOUTUBE_PICKER_MODAL,
|
||||
preferModalWindow: true,
|
||||
});
|
||||
|
||||
if (!sendPickerOpen()) {
|
||||
return false;
|
||||
}
|
||||
if (await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
deps.logWarn(
|
||||
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: YOUTUBE_PICKER_MODAL,
|
||||
timeoutMs: YOUTUBE_PICKER_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
deps.sendToActiveOverlayWindow('youtube:picker-open', payload, {
|
||||
restoreOnModalClose: YOUTUBE_PICKER_MODAL,
|
||||
preferModalWindow: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!sendPickerOpen()) {
|
||||
return false;
|
||||
}
|
||||
return await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
@@ -113,3 +113,12 @@ test('applyStartupState preserves cleared startup-only runtime flags', () => {
|
||||
|
||||
assert.equal(appState.initialArgs?.settings, true);
|
||||
});
|
||||
|
||||
test('createAppState starts with session bindings marked uninitialized', () => {
|
||||
const appState = createAppState({
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
texthookerPort: 4000,
|
||||
});
|
||||
|
||||
assert.equal(appState.sessionBindingsInitialized, false);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BrowserWindow, Extension, Session } from 'electron';
|
||||
|
||||
import type {
|
||||
CompiledSessionBinding,
|
||||
Keybinding,
|
||||
MpvSubtitleRenderMetrics,
|
||||
SecondarySubMode,
|
||||
@@ -170,6 +171,8 @@ export interface AppState {
|
||||
anilistClientSecretState: AnilistSecretResolutionState;
|
||||
mecabTokenizer: MecabTokenizer | null;
|
||||
keybindings: Keybinding[];
|
||||
sessionBindings: CompiledSessionBinding[];
|
||||
sessionBindingsInitialized: boolean;
|
||||
subtitleTimingTracker: SubtitleTimingTracker | null;
|
||||
immersionTracker: ImmersionTrackerService | null;
|
||||
ankiIntegration: AnkiIntegration | null;
|
||||
@@ -252,6 +255,8 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
||||
anilistClientSecretState: createInitialAnilistSecretResolutionState(),
|
||||
mecabTokenizer: null,
|
||||
keybindings: [],
|
||||
sessionBindings: [],
|
||||
sessionBindingsInitialized: false,
|
||||
subtitleTimingTracker: null,
|
||||
immersionTracker: null,
|
||||
ankiIntegration: null,
|
||||
|
||||
Reference in New Issue
Block a user