diff --git a/changes/fix-timing-review-cancellation.md b/changes/fix-timing-review-cancellation.md
new file mode 100644
index 00000000..ffd18909
--- /dev/null
+++ b/changes/fix-timing-review-cancellation.md
@@ -0,0 +1,4 @@
+type: fixed
+area: anki
+
+- Closing the overlay while media timing review is still loading now cancels setup and modal retries, restores playback if the review paused it, and cleans up the hidden preview player.
diff --git a/changes/runtime-ownership.md b/changes/runtime-ownership.md
new file mode 100644
index 00000000..5182284b
--- /dev/null
+++ b/changes/runtime-ownership.md
@@ -0,0 +1,4 @@
+type: fixed
+area: overlay
+
+- Cancel pending Linux overlay window replacements during teardown so a delayed close callback cannot reopen the overlay.
diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md
index ee88db45..d6812179 100644
--- a/docs-site/anki-integration.md
+++ b/docs-site/anki-integration.md
@@ -214,6 +214,8 @@ Confirming writes the combined lines to the sentence field. Reset drops the adde
Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session.
+If SubMiner closes the overlay while a timing review is still loading, it cancels pending setup and modal retries and restores playback if the review paused it. A new timing review can start after the overlay reopens.
+
### Screenshots (static)
A single frame is captured at the current playback position.
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 795ed49c..1f65d433 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -30,6 +30,7 @@ Update checks and startup launcher migration share a serialized update-state sto
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
+- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
diff --git a/src/main.ts b/src/main.ts
index a9be66f0..c3c60dd9 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -49,10 +49,7 @@ import {
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
updateLinuxMpvFullscreenOverlayRefreshBurst,
} from './main/runtime/linux-mpv-fullscreen-overlay-refresh';
-import {
- resolveLinuxVisibleOverlayWindowModeAction,
- type LinuxVisibleOverlayWindowMode,
-} from './main/runtime/linux-visible-overlay-window-mode';
+import { createLinuxOverlayModeRuntime } from './main/runtime/linux-overlay-mode-runtime';
import { shouldRunLinuxOverlayZOrderKeepAlive } from './main/runtime/linux-overlay-zorder-keepalive';
import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus';
import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff';
@@ -1982,11 +1979,27 @@ let lastObservedTimePos = 0;
let lastObservedPrimarySubtitleTrackId: number | null = null;
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
null;
-let linuxVisibleOverlayWindowMode: LinuxVisibleOverlayWindowMode = 'managed';
-let linuxTrackedMpvFullscreen = false;
-let linuxTrackedMpvFullscreenChangedAtMs = 0;
-let linuxVisibleOverlayOwnerBindingKey: string | null = null;
-let linuxVisibleOverlayWindowModeSwitchToken = 0;
+const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime({
+ isEnabled: shouldRunLinuxOverlayZOrderKeepAlive,
+ isVisible: () => overlayManager.getVisibleOverlayVisible(),
+ getWindow: () => overlayManager.getMainWindow(),
+ clearWindow: () => overlayManager.setMainWindow(null),
+ createWindow: () => {
+ visibleOverlayInteractionRuntime.resetVisibleOverlayInputState();
+ createMainWindow();
+ },
+ refreshWindow: () => {
+ const trackedGeometry = overlayGeometryRuntime.getCurrentTrackedOverlayGeometry();
+ if (trackedGeometry) overlayManager.setOverlayWindowBounds(trackedGeometry);
+ overlayVisibilityRuntime.updateVisibleOverlayVisibility();
+ void ensureOverlayMpvSubtitlesHidden();
+ if (appState.currentSubText.trim()) {
+ subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
+ }
+ },
+ now: Date.now,
+ logDebug: (message) => logger.debug(message),
+});
let subtitleSidebarRequestedOpen = false;
const SEEK_THRESHOLD_SECONDS = 3;
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
@@ -2746,7 +2759,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
},
hideNonNativeOverlayWhenTargetUnfocused: () =>
shouldRunLinuxOverlayZOrderKeepAlive() &&
- linuxVisibleOverlayWindowMode === 'fullscreen-override',
+ linuxOverlayModeRuntime.mode === 'fullscreen-override',
resolveFallbackBounds: () => {
const cursorPoint = screen.getCursorScreenPoint();
const display = screen.getDisplayNearestPoint(cursorPoint);
@@ -2792,9 +2805,9 @@ const visibleOverlayInteractionRuntime = createVisibleOverlayInteractionRuntime(
getBackendOverride: () => appState.backendOverride,
getInitialArgs: () => appState.initialArgs,
getOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
- getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode,
+ getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
- linuxVisibleOverlayOwnerBindingKey = key;
+ linuxOverlayModeRuntime.ownerBindingKey = key;
},
bindVisibleOverlayToTrackedX11Window: (window) =>
overlayGeometryRuntime.bindVisibleOverlayToTrackedX11Window(window),
@@ -2952,7 +2965,8 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
startTime: range.startTime,
endTime: range.endTime,
}),
- openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
+ openModal: (payload, signal) =>
+ openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload, signal),
onPreviewEnded: (reviewId) => {
// The review may live in either overlay window; the renderer ignores foreign review ids.
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
@@ -3989,6 +4003,7 @@ const {
clearWindowsVisibleOverlayForegroundPollLoop: () =>
visibleOverlayInteractionRuntime.clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
+ linuxOverlayModeRuntime.cancelPendingTransition();
cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
},
@@ -4709,7 +4724,7 @@ const {
},
overlayVisibilityRuntime,
syncVisibleOverlayMpvFullscreenMode: (nextFullscreen) =>
- syncLinuxVisibleOverlayMpvFullscreenMode(nextFullscreen),
+ linuxOverlayModeRuntime.sync(nextFullscreen),
getOverlayInteractionActive: () =>
visibleOverlayInteractionRuntime.getVisibleOverlayInteractionActive() ||
visibleOverlayInteractionRuntime.getLinuxOverlayInputShapeActive(),
@@ -5021,14 +5036,14 @@ const overlayGeometryRuntime = createOverlayGeometryRuntime({
getTrackedWindowNativeId: () => appState.windowTracker?.getTargetWindowNativeId?.(),
getStatsOverlayVisible: () => appState.statsOverlayVisible,
getOverlayForegroundSeparateWindows: () => getOverlayForegroundSeparateWindows(),
- getLinuxVisibleOverlayWindowMode: () => linuxVisibleOverlayWindowMode,
- getLinuxTrackedMpvFullscreen: () => linuxTrackedMpvFullscreen,
- getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxTrackedMpvFullscreenChangedAtMs,
+ getLinuxVisibleOverlayWindowMode: () => linuxOverlayModeRuntime.mode,
+ getLinuxTrackedMpvFullscreen: () => linuxOverlayModeRuntime.fullscreen,
+ getLinuxTrackedMpvFullscreenChangedAtMs: () => linuxOverlayModeRuntime.fullscreenChangedAtMs,
syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen) =>
- syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen),
- getLinuxVisibleOverlayOwnerBindingKey: () => linuxVisibleOverlayOwnerBindingKey,
+ linuxOverlayModeRuntime.sync(fullscreen),
+ getLinuxVisibleOverlayOwnerBindingKey: () => linuxOverlayModeRuntime.ownerBindingKey,
setLinuxVisibleOverlayOwnerBindingKey: (key) => {
- linuxVisibleOverlayOwnerBindingKey = key;
+ linuxOverlayModeRuntime.ownerBindingKey = key;
},
clearVisibleOverlayX11OwnerBinding: (window) =>
visibleOverlayInteractionRuntime.clearVisibleOverlayX11OwnerBinding(window),
@@ -5113,85 +5128,6 @@ function createMainWindow(): BrowserWindow {
return window;
}
-function createLinuxVisibleOverlayWindowForCurrentMode(token: number, fullscreen: boolean): void {
- if (token !== linuxVisibleOverlayWindowModeSwitchToken) {
- return;
- }
- if (!overlayManager.getVisibleOverlayVisible()) {
- return;
- }
- const existingWindow = overlayManager.getMainWindow();
- if (existingWindow && !existingWindow.isDestroyed()) {
- return;
- }
-
- visibleOverlayInteractionRuntime.resetVisibleOverlayInputState();
- createMainWindow();
- const trackedGeometry = overlayGeometryRuntime.getCurrentTrackedOverlayGeometry();
- if (trackedGeometry) {
- overlayManager.setOverlayWindowBounds(trackedGeometry);
- }
- overlayVisibilityRuntime.updateVisibleOverlayVisibility();
- void ensureOverlayMpvSubtitlesHidden();
- if (appState.currentSubText.trim()) {
- subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
- }
- logger.debug(
- `Switched Linux visible overlay window mode to ${linuxVisibleOverlayWindowMode} for mpv fullscreen=${fullscreen}`,
- );
-}
-
-function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void {
- if (!shouldRunLinuxOverlayZOrderKeepAlive()) {
- return;
- }
- if (linuxTrackedMpvFullscreen !== fullscreen) {
- linuxTrackedMpvFullscreenChangedAtMs = Date.now();
- }
- linuxTrackedMpvFullscreen = fullscreen;
- const currentWindow = overlayManager.getMainWindow();
- const hasLiveWindow = Boolean(currentWindow && !currentWindow.isDestroyed());
- const action = resolveLinuxVisibleOverlayWindowModeAction({
- currentMode: linuxVisibleOverlayWindowMode,
- fullscreen,
- hasLiveWindow,
- visibleOverlayVisible: overlayManager.getVisibleOverlayVisible(),
- });
-
- linuxVisibleOverlayWindowMode = action.nextMode;
- linuxVisibleOverlayOwnerBindingKey = null;
- linuxVisibleOverlayWindowModeSwitchToken += 1;
- const token = linuxVisibleOverlayWindowModeSwitchToken;
- if (!action.shouldCreateWindow && !action.shouldDestroyCurrentWindow) {
- return;
- }
-
- const previousWindow = currentWindow;
- if (action.shouldDestroyCurrentWindow && previousWindow && !previousWindow.isDestroyed()) {
- previousWindow.once('closed', () => {
- if (overlayManager.getMainWindow() === previousWindow) {
- overlayManager.setMainWindow(null);
- }
- if (action.createWindowTiming === 'after-current-destroyed') {
- createLinuxVisibleOverlayWindowForCurrentMode(token, fullscreen);
- }
- });
- previousWindow.hide();
- previousWindow.destroy();
- }
-
- if (!action.shouldCreateWindow) {
- logger.debug(
- `Recorded Linux visible overlay window mode ${action.nextMode} for hidden mpv fullscreen=${fullscreen}`,
- );
- return;
- }
-
- if (action.createWindowTiming === 'now') {
- createLinuxVisibleOverlayWindowForCurrentMode(token, fullscreen);
- }
-}
-
function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler();
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
@@ -6358,8 +6294,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
getLinuxX11FullscreenOverlay: () =>
shouldRunLinuxOverlayZOrderKeepAlive() &&
- linuxTrackedMpvFullscreen &&
- linuxVisibleOverlayWindowMode === 'fullscreen-override',
+ linuxOverlayModeRuntime.fullscreen &&
+ linuxOverlayModeRuntime.mode === 'fullscreen-override',
onVisibleWindowBlurred: () =>
visibleOverlayInteractionRuntime.scheduleVisibleOverlayBlurRefresh(),
onVisibleWindowFocused: () =>
diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts
index e3accd75..63f67012 100644
--- a/src/main/main-wiring.test.ts
+++ b/src/main/main-wiring.test.ts
@@ -453,7 +453,7 @@ test('Linux visible overlay recreation clears stale input state before creating
const source = readMainSource();
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
const actionBlock = source.match(
- /function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?
[\s\S]*?)\n\}/,
+ /const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?createWindow: \(\) => \{(?[\s\S]*?)\n \},/,
)?.groups?.body;
const resetBlock = runtimeSource.match(
/function resetVisibleOverlayInputState\(\): void \{(?[\s\S]*?)\n \}/,
@@ -472,7 +472,7 @@ test('Linux visible overlay recreation clears stale input state before creating
test('Linux visible overlay recreation avoids display fallback before tracked geometry exists', () => {
const source = readMainSource();
const actionBlock = source.match(
- /function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?[\s\S]*?)\n\}/,
+ /const linuxOverlayModeRuntime = createLinuxOverlayModeRuntime\(\{[\s\S]*?refreshWindow: \(\) => \{(?[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(actionBlock);
@@ -480,7 +480,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
actionBlock,
/const trackedGeometry = overlayGeometryRuntime\.getCurrentTrackedOverlayGeometry\(\);/,
);
- assert.match(actionBlock, /if \(trackedGeometry\) \{/);
+ assert.match(
+ actionBlock,
+ /if \(trackedGeometry\) overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/,
+ );
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
});
diff --git a/src/main/runtime/composers/startup-lifecycle-composer.ts b/src/main/runtime/composers/startup-lifecycle-composer.ts
index e8e95059..adcf5a1c 100644
--- a/src/main/runtime/composers/startup-lifecycle-composer.ts
+++ b/src/main/runtime/composers/startup-lifecycle-composer.ts
@@ -8,13 +8,10 @@ import {
createBuildRestoreWindowsOnActivateMainDepsHandler,
createBuildShouldRestoreWindowsOnActivateMainDepsHandler,
} from '../app-lifecycle-main-activate';
-import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from '../protocol-url-handlers-main-deps';
import { registerProtocolUrlHandlers } from '../protocol-url-handlers';
import type { ComposerInputs, ComposerOutputs } from './contracts';
-type RegisterProtocolUrlHandlersMainDeps = Parameters<
- typeof createBuildRegisterProtocolUrlHandlersMainDepsHandler
->[0];
+type RegisterProtocolUrlHandlersMainDeps = Parameters[0];
type OnWillQuitCleanupDeps = Parameters[0];
type ShouldRestoreWindowsOnActivateMainDeps = Parameters<
typeof createBuildShouldRestoreWindowsOnActivateMainDepsHandler
@@ -40,10 +37,6 @@ export type StartupLifecycleComposerResult = ComposerOutputs<{
export function composeStartupLifecycleHandlers(
options: StartupLifecycleComposerOptions,
): StartupLifecycleComposerResult {
- const registerProtocolUrlHandlersMainDeps = createBuildRegisterProtocolUrlHandlersMainDepsHandler(
- options.registerProtocolUrlHandlersMainDeps,
- )();
-
const onWillQuitCleanupHandler = createOnWillQuitCleanupHandler(
createBuildOnWillQuitCleanupDepsHandler(options.onWillQuitCleanupMainDeps)(),
);
@@ -58,9 +51,9 @@ export function composeStartupLifecycleHandlers(
return {
registerProtocolUrlHandlers: () =>
- registerProtocolUrlHandlers(registerProtocolUrlHandlersMainDeps),
- onWillQuitCleanup: () => onWillQuitCleanupHandler(),
- shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
- restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
+ registerProtocolUrlHandlers(options.registerProtocolUrlHandlersMainDeps),
+ onWillQuitCleanup: onWillQuitCleanupHandler,
+ shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler,
+ restoreWindowsOnActivate: restoreWindowsOnActivateHandler,
};
}
diff --git a/src/main/runtime/domains/anilist.ts b/src/main/runtime/domains/anilist.ts
index 6650c4ca..7cee9eda 100644
--- a/src/main/runtime/domains/anilist.ts
+++ b/src/main/runtime/domains/anilist.ts
@@ -13,4 +13,3 @@ export * from '../anilist-state';
export * from '../anilist-token-refresh';
export * from '../anilist-token-refresh-main-deps';
export * from '../protocol-url-handlers';
-export * from '../protocol-url-handlers-main-deps';
diff --git a/src/main/runtime/linux-overlay-mode-runtime.test.ts b/src/main/runtime/linux-overlay-mode-runtime.test.ts
new file mode 100644
index 00000000..2ef3350d
--- /dev/null
+++ b/src/main/runtime/linux-overlay-mode-runtime.test.ts
@@ -0,0 +1,93 @@
+import assert from 'node:assert/strict';
+import { EventEmitter } from 'node:events';
+import { test } from 'node:test';
+import { createLinuxOverlayModeRuntime } from './linux-overlay-mode-runtime';
+
+class TestWindow extends EventEmitter {
+ destroyed = false;
+ hidden = false;
+ isDestroyed() {
+ return this.destroyed;
+ }
+ hide() {
+ this.hidden = true;
+ }
+ destroy() {
+ this.destroyed = true;
+ }
+ finishClose() {
+ this.emit('closed');
+ }
+}
+
+function fixture() {
+ const initial = new TestWindow();
+ const state: { window: TestWindow | null; visible: boolean; creates: number; refreshes: number } =
+ {
+ window: initial,
+ visible: true,
+ creates: 0,
+ refreshes: 0,
+ };
+ const runtime = createLinuxOverlayModeRuntime({
+ isEnabled: () => true,
+ isVisible: () => state.visible,
+ getWindow: () => state.window,
+ clearWindow: () => {
+ state.window = null;
+ },
+ createWindow: () => {
+ state.creates += 1;
+ state.window = new TestWindow();
+ },
+ refreshWindow: () => {
+ state.refreshes += 1;
+ },
+ now: () => 42,
+ logDebug: () => {},
+ });
+ return { initial, state, runtime };
+}
+
+test('Linux mode transition waits for close before replacing and refreshing the window', () => {
+ const { initial, state, runtime } = fixture();
+ runtime.ownerBindingKey = 'old-owner';
+ runtime.sync(true);
+ assert.equal(runtime.mode, 'fullscreen-override');
+ assert.equal(runtime.fullscreenChangedAtMs, 42);
+ assert.equal(runtime.ownerBindingKey, null);
+ assert.equal(initial.hidden, true);
+ assert.equal(state.creates, 0);
+ initial.finishClose();
+ assert.equal(state.creates, 1);
+ assert.equal(state.refreshes, 1);
+ runtime.sync(true);
+ assert.equal(state.creates, 1);
+});
+
+test('an older close callback cannot clear or replace a newer overlay', () => {
+ const { initial, state, runtime } = fixture();
+ runtime.sync(true);
+ runtime.sync(false);
+ const replacement = state.window;
+ assert.equal(state.creates, 1);
+ initial.finishClose();
+ assert.equal(state.window, replacement);
+ assert.equal(state.creates, 1);
+ assert.equal(runtime.mode, 'managed');
+});
+
+test('hiding or cancelling a transition prevents delayed window creation', () => {
+ for (const cancel of [false, true]) {
+ const { initial, state, runtime } = fixture();
+ runtime.sync(true);
+ if (cancel) runtime.cancelPendingTransition();
+ else state.visible = false;
+ initial.finishClose();
+ assert.equal(state.creates, 0);
+ assert.equal(state.window, null);
+ state.visible = true;
+ runtime.sync(true);
+ assert.equal(state.creates, 1);
+ }
+});
diff --git a/src/main/runtime/linux-overlay-mode-runtime.ts b/src/main/runtime/linux-overlay-mode-runtime.ts
new file mode 100644
index 00000000..d2bed651
--- /dev/null
+++ b/src/main/runtime/linux-overlay-mode-runtime.ts
@@ -0,0 +1,94 @@
+import type { BrowserWindow } from 'electron';
+import {
+ resolveLinuxVisibleOverlayWindowModeAction,
+ type LinuxVisibleOverlayWindowMode,
+} from './linux-visible-overlay-window-mode';
+
+type OverlayWindow = Pick & {
+ once: (event: 'closed', listener: () => void) => unknown;
+};
+
+export function createLinuxOverlayModeRuntime(deps: {
+ isEnabled: () => boolean;
+ isVisible: () => boolean;
+ getWindow: () => Window | null;
+ clearWindow: () => void;
+ createWindow: () => void;
+ refreshWindow: () => void;
+ now: () => number;
+ logDebug: (message: string) => void;
+}) {
+ let mode: LinuxVisibleOverlayWindowMode = 'managed';
+ let fullscreen = false;
+ let fullscreenChangedAtMs = 0;
+ let ownerBindingKey: string | null = null;
+ let generation = 0;
+
+ function createWindowForMode(token: number, nextFullscreen: boolean): void {
+ if (token !== generation || !deps.isVisible()) return;
+ const existing = deps.getWindow();
+ if (existing && !existing.isDestroyed()) return;
+ deps.createWindow();
+ deps.refreshWindow();
+ deps.logDebug(
+ `Switched Linux visible overlay window mode to ${mode} for mpv fullscreen=${nextFullscreen}`,
+ );
+ }
+
+ function sync(nextFullscreen: boolean): void {
+ if (!deps.isEnabled()) return;
+ if (fullscreen !== nextFullscreen) fullscreenChangedAtMs = deps.now();
+ fullscreen = nextFullscreen;
+ const current = deps.getWindow();
+ const action = resolveLinuxVisibleOverlayWindowModeAction({
+ currentMode: mode,
+ fullscreen,
+ hasLiveWindow: Boolean(current && !current.isDestroyed()),
+ visibleOverlayVisible: deps.isVisible(),
+ });
+ mode = action.nextMode;
+ ownerBindingKey = null;
+ const token = ++generation;
+ if (!action.shouldCreateWindow && !action.shouldDestroyCurrentWindow) return;
+
+ if (action.shouldDestroyCurrentWindow && current && !current.isDestroyed()) {
+ current.once('closed', () => {
+ if (deps.getWindow() === current) deps.clearWindow();
+ if (action.createWindowTiming === 'after-current-destroyed') {
+ createWindowForMode(token, nextFullscreen);
+ }
+ });
+ current.hide();
+ current.destroy();
+ }
+ if (!action.shouldCreateWindow) {
+ deps.logDebug(
+ `Recorded Linux visible overlay window mode ${action.nextMode} for hidden mpv fullscreen=${fullscreen}`,
+ );
+ return;
+ }
+ if (action.createWindowTiming === 'now') createWindowForMode(token, nextFullscreen);
+ }
+
+ return {
+ get mode() {
+ return mode;
+ },
+ get fullscreen() {
+ return fullscreen;
+ },
+ get fullscreenChangedAtMs() {
+ return fullscreenChangedAtMs;
+ },
+ get ownerBindingKey() {
+ return ownerBindingKey;
+ },
+ set ownerBindingKey(key: string | null) {
+ ownerBindingKey = key;
+ },
+ sync,
+ cancelPendingTransition: () => {
+ generation += 1;
+ },
+ };
+}
diff --git a/src/main/runtime/media-timing-review-open.ts b/src/main/runtime/media-timing-review-open.ts
index 1d2a762d..fa6655ea 100644
--- a/src/main/runtime/media-timing-review-open.ts
+++ b/src/main/runtime/media-timing-review-open.ts
@@ -20,11 +20,13 @@ export async function openMediaTimingReviewModal(
logWarn: (message: string) => void;
},
payload: MediaTimingReviewOpenPayload,
+ signal?: AbortSignal,
): Promise {
return await retryOverlayModalOpen(
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
{
modal: MODAL,
+ signal,
// The review renderer regularly needs more than the 1.5 s the other modals allow; a
// premature retry re-sends the payload and reloads the waveform for nothing.
timeoutMs: 4_000,
diff --git a/src/main/runtime/media-timing-review.test.ts b/src/main/runtime/media-timing-review.test.ts
index fad9b587..85718b14 100644
--- a/src/main/runtime/media-timing-review.test.ts
+++ b/src/main/runtime/media-timing-review.test.ts
@@ -8,6 +8,7 @@ import type {
RemoteMediaWindowSource,
} from '../../core/services/remote-media-window-cache';
import type { MediaTimingPreviewSession } from '../../core/services/media-timing-preview';
+import { openMediaTimingReviewModal } from './media-timing-review-open';
type MediaTimingPreviewSessionLike = Pick;
import {
@@ -16,6 +17,20 @@ import {
createMediaTimingReviewRuntime,
} from './media-timing-review';
+function createDeferred() {
+ let settle: ((value: T) => void) | null = null;
+ const promise = new Promise((resolve) => {
+ settle = resolve;
+ });
+ return {
+ promise,
+ resolve(value: T): void {
+ if (!settle) throw new Error('deferred promise is unavailable');
+ settle(value);
+ },
+ };
+}
+
describe('buildMediaTimingReviewPayload', () => {
test('starts from the padded range and leaves two seconds to drag on each side', () => {
const payload = buildMediaTimingReviewPayload(
@@ -764,6 +779,206 @@ test('disposing an open review settles it with original timing and restores play
]);
});
+for (const pendingSetup of ['properties', 'video-source'] as const) {
+ test(`disposing pending ${pendingSetup} cancels side effects and permits a fresh review`, async () => {
+ const setupGate = createDeferred();
+ const commands: Array> = [];
+ let blockSetup = true;
+ let modalOpenCalls = 0;
+ let previewCreateCalls = 0;
+ let runtime: ReturnType;
+ runtime = createMediaTimingReviewRuntime({
+ getMpvClient: () => ({
+ connected: true,
+ currentVideoPath: '/video/show.mkv',
+ requestProperty: async (name) => {
+ if (blockSetup && pendingSetup === 'properties') await setupGate.promise;
+ return name === 'pause' ? false : name === 'duration' ? 100 : null;
+ },
+ send: ({ command }) => commands.push(command),
+ }),
+ resolveVideoSource: async () => {
+ if (blockSetup && pendingSetup === 'video-source') await setupGate.promise;
+ return { path: '/video/show.mkv' };
+ },
+ getCurrentMediaPath: () => '/video/show.mkv',
+ getMpvExecutablePath: () => 'mpv',
+ generateWaveform: async () => [],
+ createPreviewSession: () => {
+ previewCreateCalls += 1;
+ return {
+ start: async () => undefined,
+ play: async () => undefined,
+ stop: async () => undefined,
+ onPlaybackEnded: () => undefined,
+ dispose: () => undefined,
+ };
+ },
+ openModal: async (payload) => {
+ modalOpenCalls += 1;
+ runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'use-original' } });
+ return true;
+ },
+ showStatus: () => undefined,
+ });
+ const request = {
+ kind: 'word' as const,
+ text: '字幕',
+ startTime: 10,
+ endTime: 12,
+ audioPadding: 0,
+ maxMediaDuration: 30,
+ screenshotEnabled: true,
+ };
+
+ const pending = runtime.requestReview(request);
+ let pendingSettled = false;
+ void pending.finally(() => {
+ pendingSettled = true;
+ });
+ await Promise.resolve();
+ await runtime.dispose();
+
+ assert.equal(pendingSettled, true);
+ assert.deepEqual(await pending, { action: 'use-original' });
+ assert.deepEqual(commands, []);
+ assert.equal(modalOpenCalls, 0);
+ assert.equal(previewCreateCalls, 0);
+
+ setupGate.resolve();
+ await Promise.resolve();
+
+ blockSetup = false;
+ assert.deepEqual(await runtime.requestReview(request), { action: 'use-original' });
+ assert.equal(modalOpenCalls, 1);
+ assert.equal(previewCreateCalls, 1);
+ });
+}
+
+test('disposing during modal acknowledgement prevents the real opener from retrying', async () => {
+ const waiting = createDeferred();
+ const acknowledgement = createDeferred();
+ const commands: Array> = [];
+ let sendCalls = 0;
+ let previewDisposeCalls = 0;
+ let opening: Promise | undefined;
+ const runtime = createMediaTimingReviewRuntime({
+ getMpvClient: () => ({
+ connected: true,
+ currentVideoPath: '/video/show.mkv',
+ requestProperty: async (name) => (name === 'pause' ? false : null),
+ send: ({ command }) => commands.push(command),
+ }),
+ getCurrentMediaPath: () => '/video/show.mkv',
+ getMpvExecutablePath: () => 'mpv',
+ generateWaveform: async () => [],
+ createPreviewSession: () => ({
+ start: async () => undefined,
+ play: async () => undefined,
+ stop: async () => undefined,
+ onPlaybackEnded: () => undefined,
+ dispose: () => {
+ previewDisposeCalls += 1;
+ },
+ }),
+ openModal: (payload, signal) => {
+ opening = openMediaTimingReviewModal(
+ {
+ ensureOverlayStartupPrereqs: () => {},
+ ensureOverlayWindowsReadyForVisibilityActions: () => {},
+ sendToActiveOverlayWindow: () => {
+ sendCalls += 1;
+ return true;
+ },
+ waitForModalOpen: () => {
+ waiting.resolve();
+ return acknowledgement.promise;
+ },
+ logWarn: () => {},
+ },
+ payload,
+ signal,
+ );
+ return opening;
+ },
+ showStatus: () => {},
+ });
+ const pending = runtime.requestReview({
+ kind: 'sentence',
+ text: '字幕',
+ startTime: 10,
+ endTime: 12,
+ audioPadding: 0,
+ maxMediaDuration: 30,
+ });
+ await waiting.promise;
+ await runtime.dispose();
+ assert.deepEqual(await pending, { action: 'use-original' });
+
+ acknowledgement.resolve(false);
+ assert.equal(await opening, false);
+ assert.equal(sendCalls, 1);
+ assert.equal(previewDisposeCalls, 1);
+ assert.deepEqual(commands, [
+ ['set_property', 'pause', 'yes'],
+ ['set_property', 'pause', 'no'],
+ ]);
+});
+
+test('disposing owns a preview session whose startup is still pending', async () => {
+ const openedPayload = createDeferred();
+ const previewStarted = createDeferred();
+ const previewStartGate = createDeferred();
+ let previewDisposeCalls = 0;
+ const runtime = createMediaTimingReviewRuntime({
+ getMpvClient: () => ({
+ connected: true,
+ currentVideoPath: '/video/show.mkv',
+ requestProperty: async (name) => (name === 'duration' ? 100 : null),
+ send: () => undefined,
+ }),
+ getCurrentMediaPath: () => '/video/show.mkv',
+ getMpvExecutablePath: () => 'mpv',
+ generateWaveform: async () => [],
+ createPreviewSession: () => ({
+ start: async () => {
+ previewStarted.resolve();
+ await previewStartGate.promise;
+ },
+ play: async () => undefined,
+ stop: async () => undefined,
+ onPlaybackEnded: () => undefined,
+ dispose: () => {
+ previewDisposeCalls += 1;
+ },
+ }),
+ openModal: async (payload) => {
+ openedPayload.resolve(payload);
+ return true;
+ },
+ showStatus: () => undefined,
+ });
+
+ const pending = runtime.requestReview({
+ kind: 'sentence',
+ text: '字幕',
+ startTime: 10,
+ endTime: 12,
+ audioPadding: 0,
+ maxMediaDuration: 30,
+ });
+ await openedPayload.promise;
+ await previewStarted.promise;
+
+ await runtime.dispose();
+ assert.deepEqual(await pending, { action: 'use-original' });
+ assert.equal(previewDisposeCalls, 0);
+
+ previewStartGate.resolve();
+ await new Promise((resolve) => setImmediate(resolve));
+ assert.equal(previewDisposeCalls, 1);
+});
+
test('media timing review forwards the hidden player finishing a preview to the modal', async () => {
const endedReviewIds: string[] = [];
const playback: { ended?: () => void } = {};
diff --git a/src/main/runtime/media-timing-review.ts b/src/main/runtime/media-timing-review.ts
index c611a0c3..abb3258d 100644
--- a/src/main/runtime/media-timing-review.ts
+++ b/src/main/runtime/media-timing-review.ts
@@ -78,6 +78,15 @@ interface ActiveReview {
resolve: (decision: MediaTimingReviewDecision) => void;
}
+interface ReviewRequestLifecycle {
+ signal: AbortSignal;
+ cancelled: Promise;
+ settled: Promise;
+ isCancelled(): boolean;
+ cancel(): void;
+ markSettled(): void;
+}
+
export interface MediaTimingReviewRuntimeDeps {
getMpvClient: () => ReviewMpvClient | null;
getCurrentMediaPath: () => string | null;
@@ -101,7 +110,7 @@ export interface MediaTimingReviewRuntimeDeps {
next: MediaTimingReviewContextLine[];
};
decisionTimeoutMs?: number;
- openModal: (payload: MediaTimingReviewOpenPayload) => Promise;
+ openModal: (payload: MediaTimingReviewOpenPayload, signal: AbortSignal) => Promise;
/** Tells the modal that the hidden player finished the previewed clip. */
onPreviewEnded?: (reviewId: string) => void;
showStatus: (message: string) => void;
@@ -118,6 +127,33 @@ function booleanProperty(value: unknown): boolean | null {
return null;
}
+function createReviewRequestLifecycle(): ReviewRequestLifecycle {
+ const controller = new AbortController();
+ let resolveCancellation: (() => void) | null = null;
+ let resolveSettled: (() => void) | null = null;
+ const cancellation = new Promise((resolve) => {
+ resolveCancellation = resolve;
+ });
+ const settled = new Promise((resolve) => {
+ resolveSettled = resolve;
+ });
+ return {
+ signal: controller.signal,
+ cancelled: cancellation,
+ settled,
+ isCancelled: () => controller.signal.aborted,
+ cancel: () => {
+ if (controller.signal.aborted) return;
+ controller.abort();
+ resolveCancellation?.();
+ },
+ markSettled: () => {
+ resolveSettled?.();
+ resolveSettled = null;
+ },
+ };
+}
+
/**
* Picks the subtitle lines adjacent to the mined range that the review modal can pull
* onto the card. Parsed cues cover both directions; when none are loaded (e.g. the
@@ -245,7 +281,7 @@ export function buildMediaTimingReviewPayload(
export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDeps) {
let active: ActiveReview | null = null;
- let reviewInProgress = false;
+ let currentRequest: ReviewRequestLifecycle | null = null;
let pendingPauseRestore: ReviewMpvClient | null = null;
function restorePendingPlayback(): void {
@@ -311,14 +347,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
const previous = review.preview;
const session = deps.createPreviewSession();
- session.onPlaybackEnded(() => {
- if (active === review && review.preview?.session === started) {
- deps.onPreviewEnded?.(review.payload.reviewId);
- }
- });
const { audioTrackId, ...previewOptions } = review.previewOptions;
- const started = session
- .start({
+ const startSession = async (): Promise => {
+ if (active !== review) {
+ throw new Error('This timing review is no longer active.');
+ }
+ await session.start({
mediaPath,
...previewOptions,
// A cached window keeps one audio stream, so mpv's track id from the source no longer applies.
@@ -327,19 +361,30 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
: audioTrackId !== undefined
? { audioTrackId }
: {}),
- })
- .then(() => session)
- .catch((error) => {
+ });
+ return session;
+ };
+ const started = Promise.resolve()
+ .then(startSession)
+ .catch((error: unknown) => {
session.dispose();
throw error;
});
review.preview = { path: mediaPath, session: started };
+ session.onPlaybackEnded(() => {
+ if (active === review && review.preview?.session === started) {
+ deps.onPreviewEnded?.(review.payload.reviewId);
+ }
+ });
void started.catch(() => {});
if (previous) void previous.session.then((old) => old.dispose()).catch(() => {});
return started;
}
- async function runReview(request: MediaTimingReviewRequest): Promise {
+ async function runReview(
+ request: MediaTimingReviewRequest,
+ lifecycle: ReviewRequestLifecycle,
+ ): Promise {
const mpvClient = deps.getMpvClient();
const mediaPath =
deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || '';
@@ -348,18 +393,30 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return { action: 'use-original' };
}
+ const setupPromise = Promise.all([
+ mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
+ mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
+ mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
+ mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
+ deps.resolveMediaSource?.().catch(() => null) ?? null,
+ request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null,
+ ]);
+ const setup = await Promise.race([
+ setupPromise.then((values) => ({ kind: 'ready' as const, values })),
+ lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
+ ]);
+ if (setup.kind === 'cancelled' || lifecycle.isCancelled()) {
+ return { action: 'use-original' };
+ }
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw, resolvedSource, videoSource] =
- await Promise.all([
- mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
- mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
- mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
- mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
- deps.resolveMediaSource?.().catch(() => null) ?? null,
- request.screenshotEnabled ? (deps.resolveVideoSource?.().catch(() => null) ?? null) : null,
- ]);
+ setup.values;
const pauseState = booleanProperty(pauseRaw);
- mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
pendingPauseRestore = pauseState === false ? mpvClient : null;
+ mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
+ if (lifecycle.isCancelled()) {
+ restorePendingPlayback();
+ return { action: 'use-original' };
+ }
let contextLines: ReturnType> | undefined;
try {
@@ -423,9 +480,22 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
endTime: payload.timelineEndTime,
}).catch(() => {});
- const opened = await deps.openModal(payload).catch(() => false);
+ if (lifecycle.isCancelled() || active !== review) {
+ await cleanupActiveReview(review);
+ return { action: 'use-original' };
+ }
+ const openModal = deps.openModal(payload, lifecycle.signal).catch(() => false);
+ const openResult = await Promise.race([
+ openModal.then((opened) => ({ kind: 'opened' as const, opened })),
+ lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
+ ]);
+ if (openResult.kind === 'cancelled' || lifecycle.isCancelled() || active !== review) {
+ await cleanupActiveReview(review);
+ return { action: 'use-original' };
+ }
+ const { opened } = openResult;
if (!opened) {
- await cleanupActiveReview();
+ await cleanupActiveReview(review);
deps.showStatus('Timing review could not open. Using the original subtitle timing.');
return { action: 'use-original' };
}
@@ -434,33 +504,43 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
() => resolveDecision({ action: 'use-original' }),
Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS),
);
- let decision: MediaTimingReviewDecision;
+ let decision: MediaTimingReviewDecision = { action: 'use-original' };
try {
- decision = await decisionPromise;
+ const decisionResult = await Promise.race([
+ decisionPromise.then((value) => ({ kind: 'decided' as const, value })),
+ lifecycle.cancelled.then(() => ({ kind: 'cancelled' as const })),
+ ]);
+ if (decisionResult.kind === 'decided') {
+ decision = decisionResult.value;
+ }
} finally {
clearTimeout(decisionWatchdog);
}
- await cleanupActiveReview();
+ await cleanupActiveReview(review);
return decision;
}
async function requestReview(
request: MediaTimingReviewRequest,
): Promise {
- if (active || reviewInProgress) {
+ if (active || currentRequest) {
deps.showStatus('Finish the current timing review before mining another card.');
return { action: 'use-original' };
}
- reviewInProgress = true;
+ const lifecycle = createReviewRequestLifecycle();
+ currentRequest = lifecycle;
try {
- return await runReview(request);
+ return await runReview(request, lifecycle);
} catch {
await cleanupActiveReview();
restorePendingPlayback();
deps.showStatus('Timing review failed. Using the original subtitle timing.');
return { action: 'use-original' };
} finally {
- reviewInProgress = false;
+ if (currentRequest === lifecycle) {
+ currentRequest = null;
+ }
+ lifecycle.markSettled();
}
}
@@ -603,9 +683,18 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
try {
const previewSession = current.preview ? await current.preview.session : null;
+ if (active !== current) {
+ return staleReviewResult();
+ }
await previewSession?.stop();
+ if (active !== current) {
+ return staleReviewResult();
+ }
return { ok: true };
} catch (error) {
+ if (active !== current) {
+ return staleReviewResult();
+ }
return {
ok: false,
message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`,
@@ -641,8 +730,9 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
return { ok: true };
}
- async function cleanupActiveReview(): Promise {
+ async function cleanupActiveReview(expected?: ActiveReview): Promise {
const current = active;
+ if (expected && current !== expected) return;
active = null;
if (!current) return;
deps.clearFrameCache?.();
@@ -653,9 +743,12 @@ export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDep
}
async function dispose(): Promise {
+ const request = currentRequest;
+ request?.cancel();
active?.resolve({ action: 'use-original' });
await cleanupActiveReview();
restorePendingPlayback();
+ await request?.settled;
}
return {
diff --git a/src/main/runtime/overlay-hosted-modal-open.test.ts b/src/main/runtime/overlay-hosted-modal-open.test.ts
index adaa8552..09913807 100644
--- a/src/main/runtime/overlay-hosted-modal-open.test.ts
+++ b/src/main/runtime/overlay-hosted-modal-open.test.ts
@@ -1,6 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { openOverlayHostedModal } from './overlay-hosted-modal-open';
+import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
+
+test('retryOverlayModalOpen skips the first send when already aborted', async () => {
+ const controller = new AbortController();
+ controller.abort();
+ const unexpectedCall = () => assert.fail('aborted open must not send or wait');
+ assert.equal(
+ await retryOverlayModalOpen(
+ { waitForModalOpen: unexpectedCall, logWarn: unexpectedCall },
+ {
+ modal: 'media-timing-review',
+ timeoutMs: 4_000,
+ retryWarning: 'retry',
+ sendOpen: unexpectedCall,
+ signal: controller.signal,
+ },
+ ),
+ false,
+ );
+});
+
+for (const abortOnWait of [1, 2]) {
+ test(`retryOverlayModalOpen rejects an acknowledgement aborted during wait ${abortOnWait}`, async () => {
+ const controller = new AbortController();
+ let waitCalls = 0;
+ let sendCalls = 0;
+ const opened = await retryOverlayModalOpen(
+ {
+ waitForModalOpen: async () => {
+ waitCalls += 1;
+ if (waitCalls === abortOnWait) {
+ controller.abort();
+ return true;
+ }
+ return false;
+ },
+ logWarn: () => {},
+ },
+ {
+ modal: 'media-timing-review',
+ timeoutMs: 4_000,
+ retryWarning: 'retry',
+ sendOpen: () => {
+ sendCalls += 1;
+ return true;
+ },
+ signal: controller.signal,
+ },
+ );
+ assert.equal(opened, false);
+ assert.equal(sendCalls, abortOnWait);
+ assert.equal(waitCalls, abortOnWait);
+ });
+}
+
+test('retryOverlayModalOpen still retries other modals without a signal', async () => {
+ let sendCalls = 0;
+ const opened = await retryOverlayModalOpen(
+ { waitForModalOpen: async () => sendCalls === 2, logWarn: () => {} },
+ {
+ modal: 'runtime-options',
+ timeoutMs: 1_500,
+ retryWarning: 'retry',
+ sendOpen: () => {
+ sendCalls += 1;
+ return true;
+ },
+ },
+ );
+ assert.equal(opened, true);
+ assert.equal(sendCalls, 2);
+});
test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => {
const calls: string[] = [];
diff --git a/src/main/runtime/overlay-hosted-modal-open.ts b/src/main/runtime/overlay-hosted-modal-open.ts
index 15366ae8..f19b30f4 100644
--- a/src/main/runtime/overlay-hosted-modal-open.ts
+++ b/src/main/runtime/overlay-hosted-modal-open.ts
@@ -38,20 +38,24 @@ export async function retryOverlayModalOpen(
timeoutMs: number;
retryWarning: string;
sendOpen: () => boolean;
+ signal?: AbortSignal;
},
): Promise {
- if (!input.sendOpen()) {
+ if (input.signal?.aborted || !input.sendOpen()) {
return false;
}
- if (await deps.waitForModalOpen(input.modal, input.timeoutMs)) {
+ const opened = await deps.waitForModalOpen(input.modal, input.timeoutMs);
+ if (input.signal?.aborted) return false;
+ if (opened) {
return true;
}
deps.logWarn(input.retryWarning);
- if (!input.sendOpen()) {
+ if (input.signal?.aborted || !input.sendOpen()) {
return false;
}
- return await deps.waitForModalOpen(input.modal, input.timeoutMs);
+ const retryOpened = await deps.waitForModalOpen(input.modal, input.timeoutMs);
+ return !input.signal?.aborted && retryOpened;
}
diff --git a/src/main/runtime/protocol-url-handlers-main-deps.test.ts b/src/main/runtime/protocol-url-handlers-main-deps.test.ts
deleted file mode 100644
index 5a6087aa..00000000
--- a/src/main/runtime/protocol-url-handlers-main-deps.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import assert from 'node:assert/strict';
-import test from 'node:test';
-import { createBuildRegisterProtocolUrlHandlersMainDepsHandler } from './protocol-url-handlers-main-deps';
-
-test('protocol url handlers main deps builder maps callbacks', () => {
- const calls: string[] = [];
- const deps = createBuildRegisterProtocolUrlHandlersMainDepsHandler({
- registerOpenUrl: () => calls.push('open-register'),
- registerSecondInstance: () => calls.push('second-register'),
- handleAnilistSetupProtocolUrl: () => true,
- findAnilistSetupDeepLinkArgvUrl: () => 'subminer://anilist-setup',
- logUnhandledOpenUrl: (rawUrl) => calls.push(`open:${rawUrl}`),
- logUnhandledSecondInstanceUrl: (rawUrl) => calls.push(`second:${rawUrl}`),
- })();
-
- deps.registerOpenUrl(() => {});
- deps.registerSecondInstance(() => {});
- assert.equal(deps.handleAnilistSetupProtocolUrl('subminer://anilist-setup'), true);
- assert.equal(deps.findAnilistSetupDeepLinkArgvUrl(['x']), 'subminer://anilist-setup');
- deps.logUnhandledOpenUrl('subminer://noop');
- deps.logUnhandledSecondInstanceUrl('subminer://noop');
-
- assert.deepEqual(calls, [
- 'open-register',
- 'second-register',
- 'open:subminer://noop',
- 'second:subminer://noop',
- ]);
-});
diff --git a/src/main/runtime/protocol-url-handlers-main-deps.ts b/src/main/runtime/protocol-url-handlers-main-deps.ts
deleted file mode 100644
index a2a0554f..00000000
--- a/src/main/runtime/protocol-url-handlers-main-deps.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { registerProtocolUrlHandlers } from './protocol-url-handlers';
-
-type RegisterProtocolUrlHandlersMainDeps = Parameters[0];
-
-export function createBuildRegisterProtocolUrlHandlersMainDepsHandler(
- deps: RegisterProtocolUrlHandlersMainDeps,
-) {
- return (): RegisterProtocolUrlHandlersMainDeps => ({
- registerOpenUrl: (listener) => deps.registerOpenUrl(listener),
- registerSecondInstance: (listener) => deps.registerSecondInstance(listener),
- handleAnilistSetupProtocolUrl: (rawUrl: string) => deps.handleAnilistSetupProtocolUrl(rawUrl),
- findAnilistSetupDeepLinkArgvUrl: (argv: string[]) => deps.findAnilistSetupDeepLinkArgvUrl(argv),
- logUnhandledOpenUrl: (rawUrl: string) => deps.logUnhandledOpenUrl(rawUrl),
- logUnhandledSecondInstanceUrl: (rawUrl: string) => deps.logUnhandledSecondInstanceUrl(rawUrl),
- });
-}