mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
fix(anime): harden browser playback and modal lifecycle
- Abort upstream fetches when clients disconnect - Validate embedded modal readiness before opening - Centralize browser IPC and window lifecycle
This commit is contained in:
@@ -330,6 +330,30 @@ for (const platform of ['darwin', 'win32'] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
test('priming and IPC delivery both accept an unset document-loaded flag', () => {
|
||||
const modalWindow = createMockWindow();
|
||||
Reflect.deleteProperty(modalWindow, '__subminerOverlayDocumentLoaded');
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform: 'darwin' },
|
||||
);
|
||||
|
||||
assert.equal(runtime.primeModalWindow(), true);
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(modalWindow.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('anime browser modal keeps its document warm across close on Linux', () => {
|
||||
const modalWindow = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
|
||||
@@ -138,6 +138,13 @@ export function createOverlayModalRuntimeService(
|
||||
return null;
|
||||
};
|
||||
|
||||
const isWindowDocumentLoaded = (window: BrowserWindow): boolean => {
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
|
||||
};
|
||||
return overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] !== false;
|
||||
};
|
||||
|
||||
const isWindowReadyForIpc = (window: BrowserWindow): boolean => {
|
||||
if (window.isDestroyed()) {
|
||||
return false;
|
||||
@@ -145,13 +152,12 @@ export function createOverlayModalRuntimeService(
|
||||
if (window.webContents.isLoading()) {
|
||||
return false;
|
||||
}
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
|
||||
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
|
||||
};
|
||||
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === false) {
|
||||
if (!isWindowDocumentLoaded(window)) {
|
||||
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
|
||||
@@ -166,10 +172,7 @@ export function createOverlayModalRuntimeService(
|
||||
if (window.isDestroyed() || window.webContents.isLoading()) {
|
||||
return false;
|
||||
}
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
|
||||
};
|
||||
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] !== true) {
|
||||
if (!isWindowDocumentLoaded(window)) {
|
||||
return false;
|
||||
}
|
||||
const currentURL = window.webContents.getURL();
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as path from 'node:path';
|
||||
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
|
||||
import type { AnimeStreamMetadata } from '../../anime-bridge/episode-metadata';
|
||||
import { releaseDockIcon, retainDockIcon } from '../../core/services/dock-icon-visibility';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { AnimeBrowserPlaybackState } from '../../types/anime-browser';
|
||||
import {
|
||||
registerAnimeBrowserIpcHandlers,
|
||||
type AnimeBrowserIpcSender,
|
||||
} from './anime-browser-ipc-handlers';
|
||||
import { createAnimeBrowserRuntime, type AnimeBrowserRuntimeDeps } from './anime-browser-runtime';
|
||||
import { createOpenConfigSettingsWindowHandler } from './config-settings-window';
|
||||
import { createCreateAnimeBrowserWindowHandler } from './setup-window-factory';
|
||||
import {
|
||||
toAnimeBrowserPlaybackState,
|
||||
type StreamPlaybackMetadataStore,
|
||||
} from './stream-playback-metadata';
|
||||
|
||||
type AnimeBrowserWindow = Pick<
|
||||
BrowserWindow,
|
||||
'destroy' | 'focus' | 'isDestroyed' | 'loadFile' | 'on' | 'show' | 'webContents'
|
||||
>;
|
||||
|
||||
interface DockLike {
|
||||
show: () => Promise<void> | void;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
export interface AnimeBrowserApplicationRuntimeDeps {
|
||||
userDataPath: string;
|
||||
mainModuleDir: string;
|
||||
runtime: Omit<
|
||||
AnimeBrowserRuntimeDeps,
|
||||
| 'extensionsDir'
|
||||
| 'onBridgeState'
|
||||
| 'onPlaybackMetadata'
|
||||
| 'onPreparedPlaybackMetadata'
|
||||
| 'onQueueState'
|
||||
| 'onSearchUpdate'
|
||||
>;
|
||||
configuredExtensionsDir: () => string | undefined;
|
||||
ipcMain: {
|
||||
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
|
||||
};
|
||||
playbackMetadata: StreamPlaybackMetadataStore;
|
||||
getInitialPlaybackMetadata: () => AnimeStreamMetadata | null;
|
||||
handlePlaybackMetadata: (metadata: AnimeStreamMetadata, prepared: boolean) => void;
|
||||
getAnimeBrowserWindow: () => AnimeBrowserWindow | null;
|
||||
setAnimeBrowserWindow: (window: AnimeBrowserWindow | null) => void;
|
||||
createBrowserWindow: (options: BrowserWindowConstructorOptions) => AnimeBrowserWindow;
|
||||
promoteWindowAboveOverlay: (window: AnimeBrowserWindow) => void;
|
||||
activateApp: () => void;
|
||||
dock: DockLike | null | undefined;
|
||||
shouldRehideDockIcon: () => boolean;
|
||||
isStandaloneAnimeBrowserLaunch: () => boolean;
|
||||
isMpvConnected: () => boolean;
|
||||
ensureTray: () => void;
|
||||
requestAppQuit: () => void;
|
||||
logError: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface AnimeBrowserApplicationRuntime {
|
||||
publishPlaybackState: (mediaPath: string | null) => void;
|
||||
openWindow: () => boolean;
|
||||
}
|
||||
|
||||
/** Compose the anime browser runtime, IPC sessions, event fanout, and standalone window. */
|
||||
export function createAnimeBrowserApplicationRuntime(
|
||||
deps: AnimeBrowserApplicationRuntimeDeps,
|
||||
): AnimeBrowserApplicationRuntime {
|
||||
const sessions = new Map<string, AnimeBrowserIpcSender>();
|
||||
let playbackState: AnimeBrowserPlaybackState | null = toAnimeBrowserPlaybackState(
|
||||
deps.getInitialPlaybackMetadata(),
|
||||
);
|
||||
|
||||
const broadcast = (channel: string, payload: unknown): void => {
|
||||
const targets = new Set<AnimeBrowserIpcSender>();
|
||||
const standalone = deps.getAnimeBrowserWindow();
|
||||
if (standalone && !standalone.isDestroyed()) targets.add(standalone.webContents);
|
||||
for (const sender of sessions.values()) {
|
||||
if (!sender.isDestroyed()) targets.add(sender);
|
||||
}
|
||||
for (const sender of targets) sender.send(channel, payload);
|
||||
};
|
||||
|
||||
const publishPlaybackState = (mediaPath: string | null): void => {
|
||||
playbackState = toAnimeBrowserPlaybackState(deps.playbackMetadata.match(mediaPath));
|
||||
broadcast(IPC_CHANNELS.event.animeBrowserPlaybackState, playbackState);
|
||||
};
|
||||
|
||||
const runtime = createAnimeBrowserRuntime({
|
||||
...deps.runtime,
|
||||
extensionsDir: () => {
|
||||
const configured = deps.configuredExtensionsDir()?.trim();
|
||||
return configured && configured.length > 0
|
||||
? configured
|
||||
: path.join(deps.userDataPath, 'anime-extensions');
|
||||
},
|
||||
onPlaybackMetadata: (metadata) => {
|
||||
deps.playbackMetadata.set(metadata);
|
||||
publishPlaybackState(metadata.mediaPath);
|
||||
deps.handlePlaybackMetadata(metadata, false);
|
||||
},
|
||||
onPreparedPlaybackMetadata: (metadata) => {
|
||||
deps.playbackMetadata.set(metadata);
|
||||
deps.handlePlaybackMetadata(metadata, true);
|
||||
},
|
||||
onBridgeState: (state) => broadcast(IPC_CHANNELS.event.animeBrowserBridgeState, state),
|
||||
onSearchUpdate: (update, sessionId) => {
|
||||
const sender = sessions.get(sessionId);
|
||||
if (sender && !sender.isDestroyed()) {
|
||||
sender.send(IPC_CHANNELS.event.animeBrowserSearchUpdate, update);
|
||||
}
|
||||
},
|
||||
onQueueState: (state) => broadcast(IPC_CHANNELS.event.animeBrowserQueueState, state),
|
||||
});
|
||||
|
||||
registerAnimeBrowserIpcHandlers({
|
||||
ipcMain: deps.ipcMain,
|
||||
runtime,
|
||||
getPlaybackState: () => playbackState,
|
||||
registerSession: (sessionId, sender) => {
|
||||
if (sessions.get(sessionId) === sender) return;
|
||||
sessions.set(sessionId, sender);
|
||||
sender.once('destroyed', () => {
|
||||
if (sessions.get(sessionId) !== sender) return;
|
||||
sessions.delete(sessionId);
|
||||
runtime.releaseSession(sessionId);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
let dockIconRetained = false;
|
||||
const releaseBrowserDockIcon = (): void => {
|
||||
if (!dockIconRetained) return;
|
||||
dockIconRetained = false;
|
||||
releaseDockIcon({ dock: deps.dock, shouldRehide: deps.shouldRehideDockIcon });
|
||||
};
|
||||
|
||||
const openWindowBase = createOpenConfigSettingsWindowHandler({
|
||||
getSettingsWindow: deps.getAnimeBrowserWindow,
|
||||
setSettingsWindow: deps.setAnimeBrowserWindow,
|
||||
createSettingsWindow: createCreateAnimeBrowserWindowHandler({
|
||||
createBrowserWindow: deps.createBrowserWindow,
|
||||
preloadPath: path.join(deps.mainModuleDir, 'preload-animeui.js'),
|
||||
}),
|
||||
settingsHtmlPath: path.join(deps.mainModuleDir, 'animeui', 'index.html'),
|
||||
promoteSettingsWindowAboveOverlay: deps.promoteWindowAboveOverlay,
|
||||
activateApp: deps.activateApp,
|
||||
onClosed: () => {
|
||||
releaseBrowserDockIcon();
|
||||
if (deps.isStandaloneAnimeBrowserLaunch() && !deps.isMpvConnected()) {
|
||||
void runtime.dispose().finally(deps.requestAppQuit);
|
||||
}
|
||||
},
|
||||
log: deps.logError,
|
||||
});
|
||||
|
||||
const openWindow = (): boolean => {
|
||||
if (!dockIconRetained) {
|
||||
dockIconRetained = true;
|
||||
retainDockIcon({ dock: deps.dock });
|
||||
}
|
||||
const opened = openWindowBase();
|
||||
if (!opened) {
|
||||
releaseBrowserDockIcon();
|
||||
return false;
|
||||
}
|
||||
deps.ensureTray();
|
||||
return true;
|
||||
};
|
||||
|
||||
return { publishPlaybackState, openWindow };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { registerAnimeBrowserIpcHandlers } from './anime-browser-ipc-handlers';
|
||||
|
||||
test('anime browser preference IPC coerces values at the renderer boundary', () => {
|
||||
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
|
||||
const received: unknown[][] = [];
|
||||
registerAnimeBrowserIpcHandlers({
|
||||
ipcMain: {
|
||||
handle: (channel, listener) => handlers.set(channel, listener),
|
||||
},
|
||||
runtime: {
|
||||
setPreference: (...args: unknown[]) => received.push(args),
|
||||
} as never,
|
||||
});
|
||||
|
||||
const setPreference = handlers.get(IPC_CHANNELS.request.animeBrowserSetPreference);
|
||||
assert.ok(setPreference);
|
||||
setPreference({}, 'source', 'text', 'value');
|
||||
setPreference({}, 'source', 'enabled', true);
|
||||
setPreference({}, 'source', 'choices', ['one', 2, false, 'two']);
|
||||
setPreference({}, 'source', 'invalid', { nested: 'value' });
|
||||
|
||||
assert.deepEqual(received, [
|
||||
['source', 'text', 'value'],
|
||||
['source', 'enabled', true],
|
||||
['source', 'choices', ['one', 'two']],
|
||||
['source', 'invalid', ''],
|
||||
]);
|
||||
});
|
||||
@@ -104,7 +104,7 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
|
||||
runtime.getPreferences(String(sourceId)),
|
||||
);
|
||||
handle(channels.animeBrowserSetPreference, (_event, sourceId, key, value) =>
|
||||
runtime.setPreference(String(sourceId), String(key), value as string | string[] | boolean),
|
||||
runtime.setPreference(String(sourceId), String(key), toPreferenceValue(value)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,15 @@ function toOptionalId(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Keep preference values within the bridge schema's supported scalar and list types. */
|
||||
function toPreferenceValue(value: unknown): string | string[] | boolean {
|
||||
if (typeof value === 'string' || typeof value === 'boolean') return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Bridge pages are 1-based; anything unusable falls back to the first page. */
|
||||
function toPage(value: unknown): number {
|
||||
const page = Number(value);
|
||||
|
||||
Reference in New Issue
Block a user