Files
SubMiner/src/main/runtime/anime-browser-ipc-handlers.ts
T
sudacode 68dd789fbf feat(anime): remember the default Anime Browser source
- Add `anime.defaultSource` with installed-source fallback
- Let the source picker save a source or All sources as default
2026-09-02 23:17:20 -07:00

191 lines
7.9 KiB
TypeScript

import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { AnimeBrowserRuntime } from './anime-browser-runtime';
import type {
AnimeBrowserPlayRequest,
AnimeBrowserSetWatchedRequest,
AnimeBrowserWatchStateRequest,
} from '../../types/anime-browser';
export interface AnimeBrowserIpcDeps {
// Structurally typed so tests can pass a fake without importing Electron.
ipcMain: {
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): unknown;
};
runtime: AnimeBrowserRuntime;
getPlaybackState?: () => unknown;
registerSession?: (sessionId: string, sender: AnimeBrowserIpcSender) => void;
}
export interface AnimeBrowserIpcSender {
send(channel: string, ...args: unknown[]): void;
isDestroyed(): boolean;
once(event: 'destroyed', listener: () => void): unknown;
}
interface AnimeBrowserIpcEvent {
sender: AnimeBrowserIpcSender;
}
/**
* Bridge the renderer to the anime runtime. Page arguments arrive as `unknown`
* from the renderer, so they are coerced here rather than trusted.
*/
export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void {
const channels = IPC_CHANNELS.request;
const { runtime } = deps;
const handle = (
channel: string,
listener: (event: unknown, ...args: unknown[]) => unknown,
): void => {
deps.ipcMain.handle(channel, listener);
};
handle(channels.animeBrowserGetSnapshot, (event, sessionId) => {
const session = registerSession(deps, event, sessionId);
return runtime.getSnapshot(session);
});
handle(channels.animeBrowserEnsureBridge, (event, sessionId) => {
registerSession(deps, event, sessionId);
return runtime.ensureBridge();
});
handle(channels.animeBrowserUpdateBridge, () => runtime.updateBridge());
handle(channels.animeBrowserSelectSource, (event, sessionId, sourceId) =>
runtime.selectSource(String(sourceId), registerSession(deps, event, sessionId)),
);
handle(channels.animeBrowserSetDefaultSource, (_event, sourceId) =>
runtime.setDefaultSource(String(sourceId)),
);
handle(channels.animeBrowserSearch, (event, sessionId, query, page) =>
runtime.search(String(query ?? ''), toPage(page), registerSession(deps, event, sessionId)),
);
handle(channels.animeBrowserGetPopular, (event, sessionId, page) =>
runtime.getPopular(toPage(page), registerSession(deps, event, sessionId)),
);
handle(channels.animeBrowserGetDetails, (event, sessionId, animeUrl, sourceId) =>
runtime.getDetails(
String(animeUrl),
toOptionalId(sourceId),
registerSession(deps, event, sessionId),
),
);
handle(channels.animeBrowserGetEpisodes, (event, sessionId, animeUrl, sourceId) =>
runtime.getEpisodes(
String(animeUrl),
toOptionalId(sourceId),
registerSession(deps, event, sessionId),
),
);
handle(channels.animeBrowserGetWatchState, (_event, request) =>
runtime.getWatchState(toWatchStateRequest(request)),
);
handle(channels.animeBrowserSetWatched, (_event, request) =>
runtime.setWatched(toSetWatchedRequest(request)),
);
handle(channels.animeBrowserListAvailableExtensions, () => runtime.listAvailableExtensions());
handle(channels.animeBrowserInstallExtension, (_event, pkg) =>
runtime.installExtension(String(pkg)),
);
handle(channels.animeBrowserUpdateAllExtensions, () => runtime.updateAllExtensions());
handle(channels.animeBrowserRemoveExtension, (_event, pkg) =>
runtime.removeExtension(String(pkg)),
);
handle(channels.animeBrowserRescanExtensions, () => runtime.rescanExtensions());
handle(channels.animeBrowserAddRepo, (_event, url) => runtime.addRepo(String(url)));
handle(channels.animeBrowserRemoveRepo, (_event, url) => runtime.removeRepo(String(url)));
handle(channels.animeBrowserPlayEpisode, (_event, request) =>
runtime.playEpisode(toPlayRequest(request)),
);
handle(channels.animeBrowserQueueEpisode, (_event, request) =>
runtime.queueEpisode(toPlayRequest(request)),
);
handle(channels.animeBrowserDequeueEpisode, (_event, sourceId, episodeUrl) =>
runtime.dequeueEpisode(String(sourceId ?? ''), String(episodeUrl ?? '')),
);
handle(channels.animeBrowserClearQueue, () => runtime.clearQueue());
handle(channels.animeBrowserGetQueue, () => runtime.getQueue());
handle(channels.animeBrowserGetPlaybackState, () => deps.getPlaybackState?.() ?? null);
handle(channels.animeBrowserIsPlaying, () => runtime.isPlaying());
handle(channels.animeBrowserGetPreferences, (_event, sourceId) =>
runtime.getPreferences(String(sourceId)),
);
handle(channels.animeBrowserSetPreference, (_event, sourceId, key, value) =>
runtime.setPreference(String(sourceId), String(key), toPreferenceValue(value)),
);
}
function registerSession(deps: AnimeBrowserIpcDeps, event: unknown, sessionId: unknown): string {
const normalized = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : 'default';
const sender = (event as AnimeBrowserIpcEvent | undefined)?.sender;
if (sender) deps.registerSession?.(normalized, sender);
return normalized;
}
/** Coerce a play (or queue) request at the renderer trust boundary. */
function toPlayRequest(value: unknown): AnimeBrowserPlayRequest {
const request = (value ?? {}) as Partial<AnimeBrowserPlayRequest>;
return {
sourceId: String(request.sourceId ?? ''),
animeUrl: String(request.animeUrl ?? ''),
animeTitle: String(request.animeTitle ?? ''),
episodeUrl: String(request.episodeUrl ?? ''),
episodeName: String(request.episodeName ?? ''),
// NaN and Infinity are numbers as far as typeof is concerned, and either
// one would reach the stats row as a nonsense episode number.
episodeNumber: Number.isFinite(request.episodeNumber) ? request.episodeNumber! : null,
};
}
/** Coerce a watch-state request; the renderer's arrays arrive untyped. */
function toWatchStateRequest(value: unknown): AnimeBrowserWatchStateRequest {
const request = (value ?? {}) as Partial<AnimeBrowserWatchStateRequest>;
return {
sourceId: String(request.sourceId ?? ''),
animeUrl: String(request.animeUrl ?? ''),
episodeUrls: Array.isArray(request.episodeUrls)
? request.episodeUrls.filter((url): url is string => typeof url === 'string')
: [],
};
}
/** Coerce a manual watch-mark request, including its per-episode entries. */
function toSetWatchedRequest(value: unknown): AnimeBrowserSetWatchedRequest {
const request = (value ?? {}) as Partial<AnimeBrowserSetWatchedRequest>;
const episodes = Array.isArray(request.episodes) ? request.episodes : [];
return {
sourceId: String(request.sourceId ?? ''),
animeUrl: String(request.animeUrl ?? ''),
animeTitle: String(request.animeTitle ?? ''),
episodes: episodes.map((episode) => ({
episodeUrl: String(episode?.episodeUrl ?? ''),
episodeName: String(episode?.episodeName ?? ''),
// NaN and Infinity are numbers as far as typeof is concerned, and either
// one would reach the stats row as a nonsense episode number.
episodeNumber: Number.isFinite(episode?.episodeNumber) ? episode.episodeNumber! : null,
})),
watched: request.watched === true,
};
}
/**
* A source id the renderer may omit. Absent means "use the current selection",
* so an empty value must stay undefined rather than becoming the string "".
*/
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);
return Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1;
}