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:
2026-08-16 01:21:07 -07:00
parent 877f350353
commit 4300517da7
11 changed files with 413 additions and 217 deletions
@@ -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', ''],
]);
});
+10 -1
View File
@@ -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);