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
+32 -5
View File
@@ -165,6 +165,11 @@ export interface StreamStripProxyHandle {
close: () => Promise<void>;
}
interface ClientRequestLifecycle {
activeUpstreamRequest: http.ClientRequest | null;
closed: boolean;
}
/** Response headers that must not be forwarded verbatim. */
const DROPPED_HEADERS = new Set([
'connection',
@@ -212,7 +217,20 @@ export function startStreamStripProxy(
delete requestHeaders.range;
res.on('error', () => {});
requestUpstream(req, res, upstreamUrl, requestHeaders, 0);
const lifecycle: ClientRequestLifecycle = { activeUpstreamRequest: null, closed: false };
const destroyUpstreamOnClientClose = (): void => {
lifecycle.closed = true;
lifecycle.activeUpstreamRequest?.destroy();
};
req.once('aborted', destroyUpstreamOnClientClose);
res.once('close', destroyUpstreamOnClientClose);
res.once('finish', () => {
req.off('aborted', destroyUpstreamOnClientClose);
res.off('close', destroyUpstreamOnClientClose);
lifecycle.activeUpstreamRequest = null;
});
requestUpstream(req, res, upstreamUrl, requestHeaders, 0, lifecycle);
});
/**
@@ -227,23 +245,31 @@ export function startStreamStripProxy(
upstreamUrl: URL,
requestHeaders: http.OutgoingHttpHeaders,
attempt: number,
lifecycle: ClientRequestLifecycle,
): void {
if (lifecycle.closed || res.destroyed) return;
const mayRetry = req.method === 'GET' && attempt === 0;
// Once the response is handed off, its headers (and often part of its body)
// are already on the wire: a later upstream error can only be reported by
// killing the connection, never by retrying or writing a 502.
let handedOff = false;
const retry = (): void => {
setTimeout(
() => requestUpstream(req, res, upstreamUrl, requestHeaders, attempt + 1),
retryDelayMs,
);
setTimeout(() => {
requestUpstream(req, res, upstreamUrl, requestHeaders, attempt + 1, lifecycle);
}, retryDelayMs);
};
const upstreamRequest = http.request(
upstreamUrl,
{ method: req.method, headers: requestHeaders, timeout: UPSTREAM_TIMEOUT_MS },
(upstream) => {
const clearActiveRequest = (): void => {
if (lifecycle.activeUpstreamRequest === upstreamRequest) {
lifecycle.activeUpstreamRequest = null;
}
};
upstream.once('end', clearActiveRequest);
upstream.once('close', clearActiveRequest);
// Body streaming has its own pace; only the wait for headers is capped.
upstreamRequest.setTimeout(0);
const status = upstream.statusCode ?? 502;
@@ -260,6 +286,7 @@ export function startStreamStripProxy(
handleUpstreamResponse(req, res, upstream);
},
);
lifecycle.activeUpstreamRequest = upstreamRequest;
// Destroying with an error routes the stall through the retry/502 path.
upstreamRequest.on('timeout', () => {
upstreamRequest.destroy(new Error(`upstream silent for ${UPSTREAM_TIMEOUT_MS}ms`));
+6 -1
View File
@@ -453,7 +453,12 @@ void (async () => {
renderBridgeState({ stage: 'idle', progress: null, message: null });
// A queue survives the window being closed and reopened, so start from what
// the main process already holds rather than from empty.
void api.getQueue().then((state) => detailPanel.setQueue(state));
void api.getQueue().then(
(state) => detailPanel.setQueue(state),
() => {
// A live queue event can still populate the panel after a failed snapshot request.
},
);
void api.getPlaybackState().then(
(state) => {
if (!receivedPlaybackStateEvent) detailPanel.setPlaybackState(state);
+83 -197
View File
@@ -427,7 +427,6 @@ import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
import { releaseDockIcon, retainDockIcon } from './core/services/dock-icon-visibility';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
import { dispatchSessionAction as dispatchSessionActionCore } from './core/services/session-actions';
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './main/runtime/domains/shortcuts';
@@ -491,7 +490,6 @@ import { createMediaRuntimeService } from './main/media-runtime';
import {
createStreamPlaybackMetadataStore,
matchRequestedStreamPlaybackMetadata,
toAnimeBrowserPlaybackState,
toAnilistMediaGuess,
toJimakuMediaInfo,
} from './main/runtime/stream-playback-metadata';
@@ -542,14 +540,9 @@ import {
createCreateFirstRunSetupWindowHandler,
createCreateJellyfinSetupWindowHandler,
createCreateSyncUiWindowHandler,
createCreateAnimeBrowserWindowHandler,
} from './main/runtime/setup-window-factory';
import { createAnimeBrowserRuntime } from './main/runtime/anime-browser-runtime';
import { createAnimeBrowserApplicationRuntime } from './main/runtime/anime-browser-application-runtime';
import { openAnimeBrowserModal as openAnimeBrowserModalRuntime } from './main/runtime/anime-browser-open';
import {
registerAnimeBrowserIpcHandlers,
type AnimeBrowserIpcSender,
} from './main/runtime/anime-browser-ipc-handlers';
import { ensureBridgeBinaries } from './main/runtime/anime-bridge-installer';
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window';
@@ -2469,7 +2462,7 @@ const mediaRuntime = createMediaRuntimeService(
getSubtitlePositionsDir: () => SUBTITLE_POSITIONS_DIR,
setCurrentMediaPath: (nextPath: string | null) => {
appState.currentMediaPath = nextPath;
publishAnimeBrowserPlaybackState(nextPath);
animeBrowserApplicationRuntime.publishPlaybackState(nextPath);
},
clearPendingSubtitlePosition: () => {
appState.pendingSubtitlePosition = null;
@@ -3328,200 +3321,93 @@ const {
},
});
const DEFAULT_ANIME_EXTENSIONS_DIR = path.join(USER_DATA_PATH, 'anime-extensions');
const animeBrowserSessions = new Map<string, AnimeBrowserIpcSender>();
let animeBrowserPlaybackState = toAnimeBrowserPlaybackState(getActiveStreamMetadata());
function broadcastAnimeBrowserEvent(channel: string, payload: unknown): void {
const targets = new Set<AnimeBrowserIpcSender>();
const standalone = appState.animeBrowserWindow;
if (standalone && !standalone.isDestroyed()) targets.add(standalone.webContents);
for (const sender of animeBrowserSessions.values()) {
if (!sender.isDestroyed()) targets.add(sender);
}
for (const sender of targets) sender.send(channel, payload);
}
function publishAnimeBrowserPlaybackState(mediaPath: string | null): void {
animeBrowserPlaybackState = toAnimeBrowserPlaybackState(streamPlaybackMetadata.match(mediaPath));
broadcastAnimeBrowserEvent(
IPC_CHANNELS.event.animeBrowserPlaybackState,
animeBrowserPlaybackState,
);
}
function resolveAnimeExtensionsDir(): string {
const configured = configService.getConfig().anime?.extensionsDir?.trim();
return configured && configured.length > 0 ? configured : DEFAULT_ANIME_EXTENSIONS_DIR;
}
const animeBrowserRuntime = createAnimeBrowserRuntime({
extensionsDir: () => resolveAnimeExtensionsDir(),
repos: () => configService.getConfig().anime?.repos ?? [],
setRepos: (repos) => {
configService.patchRawConfig({ anime: { repos } });
},
preferredQuality: () => configService.getConfig().anime?.preferredQuality || undefined,
preferencesFile: path.join(USER_DATA_PATH, 'anime-source-preferences.json'),
ensureBinaries: (onProgress) =>
ensureBridgeBinaries({
installDir: path.join(USER_DATA_PATH, 'anime-bridge'),
onProgress,
}),
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
ensureMpvConnected: () => ensureMpvConnectedForPlayback(),
onPlaybackEndFile: (listener) => {
const client = appState.mpvClient;
if (!client) return () => {};
client.on('end-file', listener);
return () => client.off('end-file', listener);
},
onPlaybackPathChange: (listener) => {
const client = appState.mpvClient;
if (!client) return () => {};
const handler = ({ path: mediaPath }: { path: string }) => listener(mediaPath);
client.on('media-path-change', handler);
return () => client.off('media-path-change', handler);
},
readMpvProperty: (name) => {
const client = appState.mpvClient;
if (!client) return Promise.reject(new Error('mpv is not connected.'));
return client.requestProperty(name);
},
showVisibleOverlay: () => {
// Launching a video turns a browse-only instance into a regular SubMiner
// playback session: tray icon plus the overlay runtime that
// setVisibleOverlayVisible initializes on demand.
ensureTrayHandler();
setVisibleOverlayVisible(true);
},
showMpvOsd: (text) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }),
getWatchState: async (statsPaths) => {
// Browsing can precede any playback, so the tracker may not be up yet; it
// is the same instance playback records into once it is.
ensureImmersionTrackerStarted();
return (await appState.immersionTracker?.getStreamWatchState(statsPaths)) ?? new Map();
},
setWatchState: async (episodes, watched) => {
ensureImmersionTrackerStarted();
return (await appState.immersionTracker?.setStreamWatchState(episodes, watched)) ?? 0;
},
onPlaybackMetadata: (metadata) => {
streamPlaybackMetadata.set(metadata);
publishAnimeBrowserPlaybackState(metadata.mediaPath);
// Set before mpv reports the path change, so the session that change starts
// is titled and grouped from the source's own listing rather than from the
// proxy URL, whose only readable part is the `.m3u8` extension.
mediaRuntime.updateCurrentMediaTitle(metadata.displayTitle);
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordStreamPlaybackMetadata({
mediaPath: metadata.mediaPath,
statsPath: metadata.statsPath,
displayTitle: metadata.displayTitle,
seriesTitle: metadata.seriesTitle,
seasonNumber: metadata.seasonNumber,
episodeNumber: metadata.episodeNumber,
});
},
onPreparedPlaybackMetadata: (metadata) => {
streamPlaybackMetadata.set(metadata);
// Register the URL alias before mpv can advance to it, but do not replace
// the title of the file that is still playing now.
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordStreamPlaybackMetadata({
mediaPath: metadata.mediaPath,
statsPath: metadata.statsPath,
displayTitle: metadata.displayTitle,
seriesTitle: metadata.seriesTitle,
seasonNumber: metadata.seasonNumber,
episodeNumber: metadata.episodeNumber,
});
},
onBridgeState: (state) => {
broadcastAnimeBrowserEvent(IPC_CHANNELS.event.animeBrowserBridgeState, state);
},
onSearchUpdate: (update, sessionId) => {
const sender = animeBrowserSessions.get(sessionId);
if (sender && !sender.isDestroyed()) {
sender.send(IPC_CHANNELS.event.animeBrowserSearchUpdate, update);
}
},
onQueueState: (state) => {
broadcastAnimeBrowserEvent(IPC_CHANNELS.event.animeBrowserQueueState, state);
},
log: (message) => logger.info(message),
});
registerAnimeBrowserIpcHandlers({
ipcMain,
runtime: animeBrowserRuntime,
getPlaybackState: () => animeBrowserPlaybackState,
registerSession: (sessionId, sender) => {
if (animeBrowserSessions.get(sessionId) === sender) return;
animeBrowserSessions.set(sessionId, sender);
sender.once('destroyed', () => {
if (animeBrowserSessions.get(sessionId) !== sender) return;
animeBrowserSessions.delete(sessionId);
animeBrowserRuntime.releaseSession(sessionId);
});
},
});
let animeBrowserDockIconRetained = false;
const releaseAnimeBrowserDockIcon = (): void => {
if (!animeBrowserDockIconRetained) return;
animeBrowserDockIconRetained = false;
releaseDockIcon({
dock: app.dock,
shouldRehide: () => {
const mainWindow = overlayManager.getMainWindow();
return Boolean(mainWindow && !mainWindow.isDestroyed());
const animeBrowserApplicationRuntime = createAnimeBrowserApplicationRuntime({
userDataPath: USER_DATA_PATH,
mainModuleDir: __dirname,
configuredExtensionsDir: () => configService.getConfig().anime?.extensionsDir,
runtime: {
repos: () => configService.getConfig().anime?.repos ?? [],
setRepos: (repos) => configService.patchRawConfig({ anime: { repos } }),
preferredQuality: () => configService.getConfig().anime?.preferredQuality || undefined,
preferencesFile: path.join(USER_DATA_PATH, 'anime-source-preferences.json'),
ensureBinaries: (onProgress) =>
ensureBridgeBinaries({
installDir: path.join(USER_DATA_PATH, 'anime-bridge'),
onProgress,
}),
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
ensureMpvConnected: () => ensureMpvConnectedForPlayback(),
onPlaybackEndFile: (listener) => {
const client = appState.mpvClient;
if (!client) return () => {};
client.on('end-file', listener);
return () => client.off('end-file', listener);
},
});
};
const openAnimeBrowserWindowBase = createOpenConfigSettingsWindowHandler({
getSettingsWindow: () => appState.animeBrowserWindow,
setSettingsWindow: (window) => {
onPlaybackPathChange: (listener) => {
const client = appState.mpvClient;
if (!client) return () => {};
const handler = ({ path: mediaPath }: { path: string }) => listener(mediaPath);
client.on('media-path-change', handler);
return () => client.off('media-path-change', handler);
},
readMpvProperty: (name) => {
const client = appState.mpvClient;
if (!client) return Promise.reject(new Error('mpv is not connected.'));
return client.requestProperty(name);
},
showVisibleOverlay: () => {
ensureTrayHandler();
setVisibleOverlayVisible(true);
},
showMpvOsd: (text) =>
overlayNotificationsRuntime.showConfiguredStatusNotification(text, { title: 'Anime' }),
getWatchState: async (statsPaths) => {
ensureImmersionTrackerStarted();
return (await appState.immersionTracker?.getStreamWatchState(statsPaths)) ?? new Map();
},
setWatchState: async (episodes, watched) => {
ensureImmersionTrackerStarted();
return (await appState.immersionTracker?.setStreamWatchState(episodes, watched)) ?? 0;
},
log: (message) => logger.info(message),
},
ipcMain,
playbackMetadata: streamPlaybackMetadata,
getInitialPlaybackMetadata: () => getActiveStreamMetadata(),
handlePlaybackMetadata: (metadata, prepared) => {
if (!prepared) {
// Set before mpv reports the path change so stats use the source title, not the proxy URL.
mediaRuntime.updateCurrentMediaTitle(metadata.displayTitle);
}
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordStreamPlaybackMetadata({
mediaPath: metadata.mediaPath,
statsPath: metadata.statsPath,
displayTitle: metadata.displayTitle,
seriesTitle: metadata.seriesTitle,
seasonNumber: metadata.seasonNumber,
episodeNumber: metadata.episodeNumber,
});
},
getAnimeBrowserWindow: () => appState.animeBrowserWindow,
setAnimeBrowserWindow: (window) => {
appState.animeBrowserWindow = window as BrowserWindow | null;
},
createSettingsWindow: createCreateAnimeBrowserWindowHandler({
createBrowserWindow: (options) => new BrowserWindow(options),
preloadPath: path.join(__dirname, 'preload-animeui.js'),
}),
settingsHtmlPath: path.join(__dirname, 'animeui', 'index.html'),
promoteSettingsWindowAboveOverlay: (window) =>
promoteSettingsWindowAboveOverlay(window as BrowserWindow),
createBrowserWindow: (options) => new BrowserWindow(options),
promoteWindowAboveOverlay: (window) => promoteSettingsWindowAboveOverlay(window as BrowserWindow),
activateApp: activateAppForForegroundWindow,
onClosed: () => {
releaseAnimeBrowserDockIcon();
// The bridge holds the stream-proxy tokens mpv is playing through, so it
// must outlive the window. Only tear it down when the window was the
// app's whole reason to be running — and once a video has launched, the
// instance has become a regular playback session (tray, overlay, mpv
// stream), so closing the browser must not kill it either.
if (appState.initialArgs?.animeBrowser && !appState.mpvClient?.connected) {
void animeBrowserRuntime.dispose().finally(() => requestAppQuit());
}
dock: app.dock,
shouldRehideDockIcon: () => {
const mainWindow = overlayManager.getMainWindow();
return Boolean(mainWindow && !mainWindow.isDestroyed());
},
log: (message) => logger.error(message),
isStandaloneAnimeBrowserLaunch: () => Boolean(appState.initialArgs?.animeBrowser),
isMpvConnected: () => Boolean(appState.mpvClient?.connected),
ensureTray: () => ensureTrayHandler(),
requestAppQuit,
logError: (message) => logger.error(message),
});
const openAnimeBrowserWindowHandler = (): boolean => {
// Restore the Dock icon *before* showing the window: while the overlay's fullscreen
// transform has left the app as a macOS accessory process it cannot become frontmost at
// all, so activating first and un-hiding after would leave the window buried.
if (!animeBrowserDockIconRetained) {
animeBrowserDockIconRetained = true;
retainDockIcon({ dock: app.dock });
}
const opened = openAnimeBrowserWindowBase();
if (!opened) {
releaseAnimeBrowserDockIcon();
return false;
}
ensureTrayHandler();
return true;
};
const openAnimeBrowserWindowHandler = animeBrowserApplicationRuntime.openWindow;
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => appState.firstRunSetupWindow,
+24
View File
@@ -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(
+12 -9
View File
@@ -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', ''],
]);
});
+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);
+38 -1
View File
@@ -17,6 +17,7 @@ test('embedded Anime Browser closes only for its own close message', () => {
const messages: Array<(event: MessageEvent) => void> = [];
const notifications: string[] = [];
let staleModalOpen = true;
let dismissClearsOtherModal = true;
let dismissOtherModalCalls = 0;
const frameWindow = {};
const closeListeners: Array<() => void> = [];
@@ -88,7 +89,7 @@ test('embedded Anime Browser closes only for its own close message', () => {
},
dismissOtherModals: () => {
dismissOtherModalCalls += 1;
staleModalOpen = false;
if (dismissClearsOtherModal) staleModalOpen = false;
},
syncSettingsModalSubtitleSuppression: () => undefined,
},
@@ -184,6 +185,42 @@ test('embedded Anime Browser closes only for its own close message', () => {
);
assert.equal(dismissOtherModalCalls, dismissCallsBeforeRepair);
assert.equal(notifications.at(-1), 'open:anime-browser');
let escapePrevented = false;
assert.equal(
modal.handleAnimeBrowserKeydown({
key: 'Escape',
preventDefault: () => {
escapePrevented = true;
},
} as KeyboardEvent),
true,
);
assert.equal(escapePrevented, true);
assert.equal(state.animeBrowserModalOpen, false);
assert.equal(modalElement.classList.contains('hidden'), true);
staleModalOpen = true;
dismissClearsOtherModal = false;
const notificationsBeforeBlockedOpen = [...notifications];
assert.equal(modal.openAnimeBrowserModal(), false);
assert.equal(state.animeBrowserModalOpen, false);
assert.equal(modalElement.classList.contains('hidden'), true);
assert.deepEqual(notifications, notificationsBeforeBlockedOpen);
staleModalOpen = false;
dismissClearsOtherModal = true;
frame.src = '';
frame.dataset.src = '';
assert.throws(
() => modal.openAnimeBrowserModal(),
/missing its embedded source/,
'iframe load failure is reported before modal state changes',
);
assert.equal(state.animeBrowserModalOpen, false);
assert.equal(modalElement.classList.contains('hidden'), true);
assert.deepEqual(notifications, notificationsBeforeBlockedOpen);
modal.disposeDomEvents();
assert.deepEqual(removedCloseListeners, closeListeners);
} finally {
+1 -1
View File
@@ -25,12 +25,12 @@ export function createAnimeBrowserModal(
if (options.modalStateReader.isAnyModalOpen()) return false;
}
ensureFrameLoaded();
ctx.state.animeBrowserModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.animeBrowserModal.classList.remove('hidden');
ctx.dom.animeBrowserModal.setAttribute('aria-hidden', 'false');
ensureFrameLoaded();
window.electronAPI.notifyOverlayModalOpened('anime-browser');
return true;
}