mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
refactor(ipc): centralize contracts and validate payloads
This commit is contained in:
153
src/core/services/anki-jimaku-ipc.test.ts
Normal file
153
src/core/services/anki-jimaku-ipc.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerAnkiJimakuIpcHandlers } from './anki-jimaku-ipc';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
|
||||
function createFakeRegistrar(): {
|
||||
registrar: {
|
||||
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void;
|
||||
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
|
||||
};
|
||||
onHandlers: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
||||
handleHandlers: Map<string, (event: unknown, ...args: unknown[]) => unknown>;
|
||||
} {
|
||||
const onHandlers = new Map<string, (event: unknown, ...args: unknown[]) => void>();
|
||||
const handleHandlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
|
||||
return {
|
||||
registrar: {
|
||||
on: (channel, listener) => {
|
||||
onHandlers.set(channel, listener);
|
||||
},
|
||||
handle: (channel, listener) => {
|
||||
handleHandlers.set(channel, listener);
|
||||
},
|
||||
},
|
||||
onHandlers,
|
||||
handleHandlers,
|
||||
};
|
||||
}
|
||||
|
||||
test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
|
||||
const { registrar, handleHandlers } = createFakeRegistrar();
|
||||
let previewCalls = 0;
|
||||
registerAnkiJimakuIpcHandlers(
|
||||
{
|
||||
setAnkiConnectEnabled: () => {},
|
||||
clearAnkiHistory: () => {},
|
||||
refreshKnownWords: async () => {},
|
||||
respondFieldGrouping: () => {},
|
||||
buildKikuMergePreview: async () => {
|
||||
previewCalls += 1;
|
||||
return { ok: true };
|
||||
},
|
||||
getJimakuMediaInfo: () => ({
|
||||
title: 'x',
|
||||
season: null,
|
||||
episode: null,
|
||||
confidence: 'high',
|
||||
filename: 'x.mkv',
|
||||
rawTitle: 'x',
|
||||
}),
|
||||
searchJimakuEntries: async () => ({ ok: true, data: [] }),
|
||||
listJimakuFiles: async () => ({ ok: true, data: [] }),
|
||||
resolveJimakuApiKey: async () => 'token',
|
||||
getCurrentMediaPath: () => '/tmp/a.mkv',
|
||||
isRemoteMediaPath: () => false,
|
||||
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
|
||||
onDownloadedSubtitle: () => {},
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
|
||||
const previewHandler = handleHandlers.get(IPC_CHANNELS.request.kikuBuildMergePreview);
|
||||
assert.ok(previewHandler);
|
||||
const invalidPreviewResult = await previewHandler!({}, null);
|
||||
assert.deepEqual(invalidPreviewResult, {
|
||||
ok: false,
|
||||
error: 'Invalid merge preview request payload',
|
||||
});
|
||||
await previewHandler!({}, { keepNoteId: 1, deleteNoteId: 2, deleteDuplicate: false });
|
||||
assert.equal(previewCalls, 1);
|
||||
|
||||
const searchHandler = handleHandlers.get(IPC_CHANNELS.request.jimakuSearchEntries);
|
||||
assert.ok(searchHandler);
|
||||
const invalidSearchResult = await searchHandler!({}, { query: 12 });
|
||||
assert.deepEqual(invalidSearchResult, {
|
||||
ok: false,
|
||||
error: { error: 'Invalid Jimaku search query payload', code: 400 },
|
||||
});
|
||||
|
||||
const filesHandler = handleHandlers.get(IPC_CHANNELS.request.jimakuListFiles);
|
||||
assert.ok(filesHandler);
|
||||
const invalidFilesResult = await filesHandler!({}, { entryId: 'x' });
|
||||
assert.deepEqual(invalidFilesResult, {
|
||||
ok: false,
|
||||
error: { error: 'Invalid Jimaku files query payload', code: 400 },
|
||||
});
|
||||
|
||||
const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.jimakuDownloadFile);
|
||||
assert.ok(downloadHandler);
|
||||
const invalidDownloadResult = await downloadHandler!({}, { entryId: 1, url: '/x' });
|
||||
assert.deepEqual(invalidDownloadResult, {
|
||||
ok: false,
|
||||
error: { error: 'Invalid Jimaku download query payload', code: 400 },
|
||||
});
|
||||
});
|
||||
|
||||
test('anki/jimaku IPC command handlers ignore malformed payloads', () => {
|
||||
const { registrar, onHandlers } = createFakeRegistrar();
|
||||
const fieldGroupingChoices: unknown[] = [];
|
||||
const enabledStates: boolean[] = [];
|
||||
registerAnkiJimakuIpcHandlers(
|
||||
{
|
||||
setAnkiConnectEnabled: (enabled) => {
|
||||
enabledStates.push(enabled);
|
||||
},
|
||||
clearAnkiHistory: () => {},
|
||||
refreshKnownWords: async () => {},
|
||||
respondFieldGrouping: (choice) => {
|
||||
fieldGroupingChoices.push(choice);
|
||||
},
|
||||
buildKikuMergePreview: async () => ({ ok: true }),
|
||||
getJimakuMediaInfo: () => ({
|
||||
title: 'x',
|
||||
season: null,
|
||||
episode: null,
|
||||
confidence: 'high',
|
||||
filename: 'x.mkv',
|
||||
rawTitle: 'x',
|
||||
}),
|
||||
searchJimakuEntries: async () => ({ ok: true, data: [] }),
|
||||
listJimakuFiles: async () => ({ ok: true, data: [] }),
|
||||
resolveJimakuApiKey: async () => 'token',
|
||||
getCurrentMediaPath: () => '/tmp/a.mkv',
|
||||
isRemoteMediaPath: () => false,
|
||||
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
|
||||
onDownloadedSubtitle: () => {},
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
|
||||
onHandlers.get(IPC_CHANNELS.command.setAnkiConnectEnabled)!({}, 'true');
|
||||
onHandlers.get(IPC_CHANNELS.command.setAnkiConnectEnabled)!({}, true);
|
||||
assert.deepEqual(enabledStates, [true]);
|
||||
|
||||
onHandlers.get(IPC_CHANNELS.command.kikuFieldGroupingRespond)!({}, null);
|
||||
onHandlers.get(IPC_CHANNELS.command.kikuFieldGroupingRespond)!(
|
||||
{},
|
||||
{
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(fieldGroupingChoices, [
|
||||
{
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ipcMain, IpcMainEvent } from 'electron';
|
||||
import { ipcMain } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
KikuMergePreviewRequest,
|
||||
KikuMergePreviewResponse,
|
||||
} from '../../types';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseJimakuDownloadQuery,
|
||||
parseJimakuFilesQuery,
|
||||
parseJimakuSearchQuery,
|
||||
parseKikuFieldGroupingChoice,
|
||||
parseKikuMergePreviewRequest,
|
||||
} from '../../shared/ipc/validators';
|
||||
|
||||
const logger = createLogger('main:anki-jimaku-ipc');
|
||||
|
||||
@@ -39,54 +47,85 @@ export interface AnkiJimakuIpcDeps {
|
||||
onDownloadedSubtitle: (pathToSubtitle: string) => void;
|
||||
}
|
||||
|
||||
export function registerAnkiJimakuIpcHandlers(deps: AnkiJimakuIpcDeps): void {
|
||||
ipcMain.on('set-anki-connect-enabled', (_event: IpcMainEvent, enabled: boolean) => {
|
||||
interface IpcMainRegistrar {
|
||||
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void;
|
||||
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
|
||||
}
|
||||
|
||||
export function registerAnkiJimakuIpcHandlers(
|
||||
deps: AnkiJimakuIpcDeps,
|
||||
ipc: IpcMainRegistrar = ipcMain,
|
||||
): void {
|
||||
ipc.on(IPC_CHANNELS.command.setAnkiConnectEnabled, (_event: unknown, enabled: unknown) => {
|
||||
if (typeof enabled !== 'boolean') return;
|
||||
deps.setAnkiConnectEnabled(enabled);
|
||||
});
|
||||
|
||||
ipcMain.on('clear-anki-connect-history', () => {
|
||||
ipc.on(IPC_CHANNELS.command.clearAnkiConnectHistory, () => {
|
||||
deps.clearAnkiHistory();
|
||||
});
|
||||
|
||||
ipcMain.on('anki:refresh-known-words', async () => {
|
||||
ipc.on(IPC_CHANNELS.command.refreshKnownWords, async () => {
|
||||
await deps.refreshKnownWords();
|
||||
});
|
||||
|
||||
ipcMain.on(
|
||||
'kiku:field-grouping-respond',
|
||||
(_event: IpcMainEvent, choice: KikuFieldGroupingChoice) => {
|
||||
deps.respondFieldGrouping(choice);
|
||||
ipc.on(IPC_CHANNELS.command.kikuFieldGroupingRespond, (_event: unknown, choice: unknown) => {
|
||||
const parsedChoice = parseKikuFieldGroupingChoice(choice);
|
||||
if (!parsedChoice) return;
|
||||
deps.respondFieldGrouping(parsedChoice);
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.kikuBuildMergePreview,
|
||||
async (_event, request: unknown): Promise<KikuMergePreviewResponse> => {
|
||||
const parsedRequest = parseKikuMergePreviewRequest(request);
|
||||
if (!parsedRequest) {
|
||||
return { ok: false, error: 'Invalid merge preview request payload' };
|
||||
}
|
||||
return deps.buildKikuMergePreview(parsedRequest);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'kiku:build-merge-preview',
|
||||
async (_event, request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> => {
|
||||
return deps.buildKikuMergePreview(request);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle('jimaku:get-media-info', (): JimakuMediaInfo => {
|
||||
ipc.handle(IPC_CHANNELS.request.jimakuGetMediaInfo, (): JimakuMediaInfo => {
|
||||
return deps.getJimakuMediaInfo();
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'jimaku:search-entries',
|
||||
async (_event, query: JimakuSearchQuery): Promise<JimakuApiResponse<JimakuEntry[]>> => {
|
||||
return deps.searchJimakuEntries(query);
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.jimakuSearchEntries,
|
||||
async (_event, query: unknown): Promise<JimakuApiResponse<JimakuEntry[]>> => {
|
||||
const parsedQuery = parseJimakuSearchQuery(query);
|
||||
if (!parsedQuery) {
|
||||
return { ok: false, error: { error: 'Invalid Jimaku search query payload', code: 400 } };
|
||||
}
|
||||
return deps.searchJimakuEntries(parsedQuery);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'jimaku:list-files',
|
||||
async (_event, query: JimakuFilesQuery): Promise<JimakuApiResponse<JimakuFileEntry[]>> => {
|
||||
return deps.listJimakuFiles(query);
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.jimakuListFiles,
|
||||
async (_event, query: unknown): Promise<JimakuApiResponse<JimakuFileEntry[]>> => {
|
||||
const parsedQuery = parseJimakuFilesQuery(query);
|
||||
if (!parsedQuery) {
|
||||
return { ok: false, error: { error: 'Invalid Jimaku files query payload', code: 400 } };
|
||||
}
|
||||
return deps.listJimakuFiles(parsedQuery);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'jimaku:download-file',
|
||||
async (_event, query: JimakuDownloadQuery): Promise<JimakuDownloadResult> => {
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.jimakuDownloadFile,
|
||||
async (_event, query: unknown): Promise<JimakuDownloadResult> => {
|
||||
const parsedQuery = parseJimakuDownloadQuery(query);
|
||||
if (!parsedQuery) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
error: 'Invalid Jimaku download query payload',
|
||||
code: 400,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = await deps.resolveJimakuApiKey();
|
||||
if (!apiKey) {
|
||||
return {
|
||||
@@ -106,7 +145,7 @@ export function registerAnkiJimakuIpcHandlers(deps: AnkiJimakuIpcDeps): void {
|
||||
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
|
||||
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jimaku-'))
|
||||
: path.dirname(path.resolve(currentMediaPath));
|
||||
const safeName = path.basename(query.name);
|
||||
const safeName = path.basename(parsedQuery.name);
|
||||
if (!safeName) {
|
||||
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
|
||||
}
|
||||
@@ -115,19 +154,21 @@ export function registerAnkiJimakuIpcHandlers(deps: AnkiJimakuIpcDeps): void {
|
||||
const baseName = ext ? safeName.slice(0, -ext.length) : safeName;
|
||||
let targetPath = path.join(mediaDir, safeName);
|
||||
if (fs.existsSync(targetPath)) {
|
||||
targetPath = path.join(mediaDir, `${baseName} (jimaku-${query.entryId})${ext}`);
|
||||
targetPath = path.join(mediaDir, `${baseName} (jimaku-${parsedQuery.entryId})${ext}`);
|
||||
let counter = 2;
|
||||
while (fs.existsSync(targetPath)) {
|
||||
targetPath = path.join(
|
||||
mediaDir,
|
||||
`${baseName} (jimaku-${query.entryId}-${counter})${ext}`,
|
||||
`${baseName} (jimaku-${parsedQuery.entryId}-${counter})${ext}`,
|
||||
);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[jimaku] download-file name="${query.name}" entryId=${query.entryId}`);
|
||||
const result = await deps.downloadToFile(query.url, targetPath, {
|
||||
logger.info(
|
||||
`[jimaku] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
|
||||
);
|
||||
const result = await deps.downloadToFile(parsedQuery.url, targetPath, {
|
||||
Authorization: apiKey,
|
||||
'User-Agent': 'SubMiner',
|
||||
});
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createIpcDepsRuntime } from './ipc';
|
||||
import { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
|
||||
interface FakeIpcRegistrar {
|
||||
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
||||
handle: Map<string, (event: unknown, ...args: unknown[]) => unknown>;
|
||||
}
|
||||
|
||||
function createFakeIpcRegistrar(): {
|
||||
registrar: {
|
||||
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void;
|
||||
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
|
||||
};
|
||||
handlers: FakeIpcRegistrar;
|
||||
} {
|
||||
const handlers: FakeIpcRegistrar = {
|
||||
on: new Map(),
|
||||
handle: new Map(),
|
||||
};
|
||||
return {
|
||||
registrar: {
|
||||
on: (channel, listener) => {
|
||||
handlers.on.set(channel, listener);
|
||||
},
|
||||
handle: (channel, listener) => {
|
||||
handlers.handle.set(channel, listener);
|
||||
},
|
||||
},
|
||||
handlers,
|
||||
};
|
||||
}
|
||||
|
||||
test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
const calls: string[] = [];
|
||||
@@ -28,7 +58,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getMpvClient: () => null,
|
||||
focusMainWindow: () => {},
|
||||
runSubsyncManual: async () => ({}),
|
||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => ({}),
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
@@ -63,3 +93,142 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
});
|
||||
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<{ id: string; value: unknown }> = [];
|
||||
const cycles: Array<{ id: string; direction: 1 | -1 }> = [];
|
||||
registerIpcHandlers(
|
||||
{
|
||||
getInvisibleWindow: () => null,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
setInvisibleIgnoreMouseEvents: () => {},
|
||||
onOverlayModalClosed: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleDevTools: () => {},
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
toggleVisibleOverlay: () => {},
|
||||
getInvisibleOverlayVisibility: () => false,
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getMpvSubtitleRenderMetrics: () => null,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: () => {},
|
||||
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
|
||||
setMecabEnabled: () => {},
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => [],
|
||||
setRuntimeOption: (id, value) => {
|
||||
calls.push({ id, value });
|
||||
return { ok: true };
|
||||
},
|
||||
cycleRuntimeOption: (id, direction) => {
|
||||
cycles.push({ id, direction });
|
||||
return { ok: true };
|
||||
},
|
||||
reportOverlayContentBounds: () => {},
|
||||
getAnilistStatus: () => ({}),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
|
||||
const setHandler = handlers.handle.get(IPC_CHANNELS.request.setRuntimeOption);
|
||||
assert.ok(setHandler);
|
||||
const invalidIdResult = await setHandler!({}, '__invalid__', true);
|
||||
assert.deepEqual(invalidIdResult, { ok: false, error: 'Invalid runtime option id' });
|
||||
const invalidValueResult = await setHandler!({}, 'anki.autoUpdateNewCards', 42);
|
||||
assert.deepEqual(invalidValueResult, {
|
||||
ok: false,
|
||||
error: 'Invalid runtime option value payload',
|
||||
});
|
||||
const validResult = await setHandler!({}, 'anki.autoUpdateNewCards', true);
|
||||
assert.deepEqual(validResult, { ok: true });
|
||||
assert.deepEqual(calls, [{ id: 'anki.autoUpdateNewCards', value: true }]);
|
||||
|
||||
const cycleHandler = handlers.handle.get(IPC_CHANNELS.request.cycleRuntimeOption);
|
||||
assert.ok(cycleHandler);
|
||||
const invalidDirection = await cycleHandler!({}, 'anki.kikuFieldGrouping', 2);
|
||||
assert.deepEqual(invalidDirection, {
|
||||
ok: false,
|
||||
error: 'Invalid runtime option cycle direction',
|
||||
});
|
||||
await cycleHandler!({}, 'anki.kikuFieldGrouping', -1);
|
||||
assert.deepEqual(cycles, [{ id: 'anki.kikuFieldGrouping', direction: -1 }]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const saves: unknown[] = [];
|
||||
const modals: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
{
|
||||
getInvisibleWindow: () => null,
|
||||
isVisibleOverlayVisible: () => false,
|
||||
setInvisibleIgnoreMouseEvents: () => {},
|
||||
onOverlayModalClosed: (modal) => {
|
||||
modals.push(modal);
|
||||
},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleDevTools: () => {},
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
toggleVisibleOverlay: () => {},
|
||||
getInvisibleOverlayVisibility: () => false,
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getMpvSubtitleRenderMetrics: () => null,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: (position) => {
|
||||
saves.push(position);
|
||||
},
|
||||
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
|
||||
setMecabEnabled: () => {},
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => [],
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
reportOverlayContentBounds: () => {},
|
||||
getAnilistStatus: () => ({}),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
|
||||
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 'bad' });
|
||||
handlers.on.get(IPC_CHANNELS.command.saveSubtitlePosition)!({}, { yPercent: 42 });
|
||||
assert.deepEqual(saves, [
|
||||
{ yPercent: 42, invisibleOffsetXPx: undefined, invisibleOffsetYPx: undefined },
|
||||
]);
|
||||
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal');
|
||||
handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync');
|
||||
assert.deepEqual(modals, ['subsync']);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import { BrowserWindow, ipcMain, IpcMainEvent } from 'electron';
|
||||
import type {
|
||||
RuntimeOptionId,
|
||||
RuntimeOptionValue,
|
||||
SubtitlePosition,
|
||||
SubsyncManualRunRequest,
|
||||
SubsyncResult,
|
||||
} from '../../types';
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseMpvCommand,
|
||||
parseOptionalForwardingOptions,
|
||||
parseOverlayHostedModal,
|
||||
parseRuntimeOptionDirection,
|
||||
parseRuntimeOptionId,
|
||||
parseRuntimeOptionValue,
|
||||
parseSubtitlePosition,
|
||||
parseSubsyncManualRunRequest,
|
||||
} from '../../shared/ipc/validators';
|
||||
|
||||
export interface IpcServiceDeps {
|
||||
getInvisibleWindow: () => WindowLike | null;
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
onOverlayModalClosed: (modal: string) => void;
|
||||
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleDevTools: () => void;
|
||||
@@ -17,7 +35,7 @@ export interface IpcServiceDeps {
|
||||
getMpvSubtitleRenderMetrics: () => unknown;
|
||||
getSubtitlePosition: () => unknown;
|
||||
getSubtitleStyle: () => unknown;
|
||||
saveSubtitlePosition: (position: unknown) => void;
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||
getMecabStatus: () => {
|
||||
available: boolean;
|
||||
enabled: boolean;
|
||||
@@ -30,11 +48,11 @@ export interface IpcServiceDeps {
|
||||
getSecondarySubMode: () => unknown;
|
||||
getCurrentSecondarySub: () => string;
|
||||
focusMainWindow: () => void;
|
||||
runSubsyncManual: (request: unknown) => Promise<unknown>;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
getAnkiConnectStatus: () => boolean;
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: string, value: unknown) => unknown;
|
||||
cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown;
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||
reportOverlayContentBounds: (payload: unknown) => void;
|
||||
getAnilistStatus: () => unknown;
|
||||
clearAnilistToken: () => void;
|
||||
@@ -66,12 +84,17 @@ interface MpvClientLike {
|
||||
currentSecondarySubText?: string;
|
||||
}
|
||||
|
||||
interface IpcMainRegistrar {
|
||||
on: (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => void;
|
||||
handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => void;
|
||||
}
|
||||
|
||||
export interface IpcDepsRuntimeOptions {
|
||||
getInvisibleWindow: () => WindowLike | null;
|
||||
getMainWindow: () => WindowLike | null;
|
||||
getVisibleOverlayVisibility: () => boolean;
|
||||
getInvisibleOverlayVisibility: () => boolean;
|
||||
onOverlayModalClosed: (modal: string) => void;
|
||||
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
@@ -81,7 +104,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
getMpvSubtitleRenderMetrics: () => unknown;
|
||||
getSubtitlePosition: () => unknown;
|
||||
getSubtitleStyle: () => unknown;
|
||||
saveSubtitlePosition: (position: unknown) => void;
|
||||
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||
getMecabTokenizer: () => MecabTokenizerLike | null;
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
@@ -89,11 +112,11 @@ export interface IpcDepsRuntimeOptions {
|
||||
getSecondarySubMode: () => unknown;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
focusMainWindow: () => void;
|
||||
runSubsyncManual: (request: unknown) => Promise<unknown>;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
getAnkiConnectStatus: () => boolean;
|
||||
getRuntimeOptions: () => unknown;
|
||||
setRuntimeOption: (id: string, value: unknown) => unknown;
|
||||
cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown;
|
||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||
reportOverlayContentBounds: (payload: unknown) => void;
|
||||
getAnilistStatus: () => unknown;
|
||||
clearAnilistToken: () => void;
|
||||
@@ -166,11 +189,13 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
};
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: IpcServiceDeps): void {
|
||||
ipcMain.on(
|
||||
'set-ignore-mouse-events',
|
||||
(event: IpcMainEvent, ignore: boolean, options: { forward?: boolean } = {}) => {
|
||||
const senderWindow = BrowserWindow.fromWebContents(event.sender);
|
||||
export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar = ipcMain): void {
|
||||
ipc.on(
|
||||
IPC_CHANNELS.command.setIgnoreMouseEvents,
|
||||
(event: unknown, ignore: unknown, options: unknown = {}) => {
|
||||
if (typeof ignore !== 'boolean') return;
|
||||
const parsedOptions = parseOptionalForwardingOptions(options);
|
||||
const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender);
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
const invisibleWindow = deps.getInvisibleWindow();
|
||||
if (
|
||||
@@ -181,151 +206,178 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
|
||||
) {
|
||||
deps.setInvisibleIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
senderWindow.setIgnoreMouseEvents(ignore, options);
|
||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on('overlay:modal-closed', (_event: IpcMainEvent, modal: string) => {
|
||||
deps.onOverlayModalClosed(modal);
|
||||
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (_event: unknown, modal: unknown) => {
|
||||
const parsedModal = parseOverlayHostedModal(modal);
|
||||
if (!parsedModal) return;
|
||||
deps.onOverlayModalClosed(parsedModal);
|
||||
});
|
||||
|
||||
ipcMain.on('open-yomitan-settings', () => {
|
||||
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
|
||||
deps.openYomitanSettings();
|
||||
});
|
||||
|
||||
ipcMain.on('quit-app', () => {
|
||||
ipc.on(IPC_CHANNELS.command.quitApp, () => {
|
||||
deps.quitApp();
|
||||
});
|
||||
|
||||
ipcMain.on('toggle-dev-tools', () => {
|
||||
ipc.on(IPC_CHANNELS.command.toggleDevTools, () => {
|
||||
deps.toggleDevTools();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-overlay-visibility', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getOverlayVisibility, () => {
|
||||
return deps.getVisibleOverlayVisibility();
|
||||
});
|
||||
|
||||
ipcMain.on('toggle-overlay', () => {
|
||||
ipc.on(IPC_CHANNELS.command.toggleOverlay, () => {
|
||||
deps.toggleVisibleOverlay();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-visible-overlay-visibility', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getVisibleOverlayVisibility, () => {
|
||||
return deps.getVisibleOverlayVisibility();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-invisible-overlay-visibility', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getInvisibleOverlayVisibility, () => {
|
||||
return deps.getInvisibleOverlayVisibility();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-current-subtitle', async () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitle, async () => {
|
||||
return await deps.tokenizeCurrentSubtitle();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-current-subtitle-raw', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitleRaw, () => {
|
||||
return deps.getCurrentSubtitleRaw();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-current-subtitle-ass', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitleAss, () => {
|
||||
return deps.getCurrentSubtitleAss();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-mpv-subtitle-render-metrics', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics, () => {
|
||||
return deps.getMpvSubtitleRenderMetrics();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-subtitle-position', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getSubtitlePosition, () => {
|
||||
return deps.getSubtitlePosition();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-subtitle-style', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getSubtitleStyle, () => {
|
||||
return deps.getSubtitleStyle();
|
||||
});
|
||||
|
||||
ipcMain.on('save-subtitle-position', (_event: IpcMainEvent, position: unknown) => {
|
||||
deps.saveSubtitlePosition(position);
|
||||
ipc.on(IPC_CHANNELS.command.saveSubtitlePosition, (_event: unknown, position: unknown) => {
|
||||
const parsedPosition = parseSubtitlePosition(position);
|
||||
if (!parsedPosition) return;
|
||||
deps.saveSubtitlePosition(parsedPosition);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-mecab-status', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
||||
return deps.getMecabStatus();
|
||||
});
|
||||
|
||||
ipcMain.on('set-mecab-enabled', (_event: IpcMainEvent, enabled: boolean) => {
|
||||
ipc.on(IPC_CHANNELS.command.setMecabEnabled, (_event: unknown, enabled: unknown) => {
|
||||
if (typeof enabled !== 'boolean') return;
|
||||
deps.setMecabEnabled(enabled);
|
||||
});
|
||||
|
||||
ipcMain.on('mpv-command', (_event: IpcMainEvent, command: (string | number)[]) => {
|
||||
deps.handleMpvCommand(command);
|
||||
ipc.on(IPC_CHANNELS.command.mpvCommand, (_event: unknown, command: unknown) => {
|
||||
const parsedCommand = parseMpvCommand(command);
|
||||
if (!parsedCommand) return;
|
||||
deps.handleMpvCommand(parsedCommand);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-keybindings', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getKeybindings, () => {
|
||||
return deps.getKeybindings();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-config-shortcuts', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => {
|
||||
return deps.getConfiguredShortcuts();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-secondary-sub-mode', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
|
||||
return deps.getSecondarySubMode();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-current-secondary-sub', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getCurrentSecondarySub, () => {
|
||||
return deps.getCurrentSecondarySub();
|
||||
});
|
||||
|
||||
ipcMain.handle('focus-main-window', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.focusMainWindow, () => {
|
||||
deps.focusMainWindow();
|
||||
});
|
||||
|
||||
ipcMain.handle('subsync:run-manual', async (_event, request: unknown) => {
|
||||
return await deps.runSubsyncManual(request);
|
||||
ipc.handle(IPC_CHANNELS.request.runSubsyncManual, async (_event, request: unknown) => {
|
||||
const parsedRequest = parseSubsyncManualRunRequest(request);
|
||||
if (!parsedRequest) {
|
||||
return { ok: false, message: 'Invalid subsync manual request payload' };
|
||||
}
|
||||
return await deps.runSubsyncManual(parsedRequest);
|
||||
});
|
||||
|
||||
ipcMain.handle('get-anki-connect-status', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getAnkiConnectStatus, () => {
|
||||
return deps.getAnkiConnectStatus();
|
||||
});
|
||||
|
||||
ipcMain.handle('runtime-options:get', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getRuntimeOptions, () => {
|
||||
return deps.getRuntimeOptions();
|
||||
});
|
||||
|
||||
ipcMain.handle('runtime-options:set', (_event, id: string, value: unknown) => {
|
||||
return deps.setRuntimeOption(id, value);
|
||||
ipc.handle(IPC_CHANNELS.request.setRuntimeOption, (_event, id: unknown, value: unknown) => {
|
||||
const parsedId = parseRuntimeOptionId(id);
|
||||
if (!parsedId) {
|
||||
return { ok: false, error: 'Invalid runtime option id' };
|
||||
}
|
||||
const parsedValue = parseRuntimeOptionValue(value);
|
||||
if (parsedValue === null) {
|
||||
return { ok: false, error: 'Invalid runtime option value payload' };
|
||||
}
|
||||
return deps.setRuntimeOption(parsedId, parsedValue);
|
||||
});
|
||||
|
||||
ipcMain.handle('runtime-options:cycle', (_event, id: string, direction: 1 | -1) => {
|
||||
return deps.cycleRuntimeOption(id, direction);
|
||||
ipc.handle(IPC_CHANNELS.request.cycleRuntimeOption, (_event, id: unknown, direction: unknown) => {
|
||||
const parsedId = parseRuntimeOptionId(id);
|
||||
if (!parsedId) {
|
||||
return { ok: false, error: 'Invalid runtime option id' };
|
||||
}
|
||||
const parsedDirection = parseRuntimeOptionDirection(direction);
|
||||
if (!parsedDirection) {
|
||||
return { ok: false, error: 'Invalid runtime option cycle direction' };
|
||||
}
|
||||
return deps.cycleRuntimeOption(parsedId, parsedDirection);
|
||||
});
|
||||
|
||||
ipcMain.on('overlay-content-bounds:report', (_event: IpcMainEvent, payload: unknown) => {
|
||||
ipc.on(IPC_CHANNELS.command.reportOverlayContentBounds, (_event: unknown, payload: unknown) => {
|
||||
deps.reportOverlayContentBounds(payload);
|
||||
});
|
||||
|
||||
ipcMain.handle('anilist:get-status', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
|
||||
return deps.getAnilistStatus();
|
||||
});
|
||||
|
||||
ipcMain.handle('anilist:clear-token', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.clearAnilistToken, () => {
|
||||
deps.clearAnilistToken();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('anilist:open-setup', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.openAnilistSetup, () => {
|
||||
deps.openAnilistSetup();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('anilist:get-queue-status', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.getAnilistQueueStatus, () => {
|
||||
return deps.getAnilistQueueStatus();
|
||||
});
|
||||
|
||||
ipcMain.handle('anilist:retry-now', async () => {
|
||||
ipc.handle(IPC_CHANNELS.request.retryAnilistNow, async () => {
|
||||
return await deps.retryAnilistQueueNow();
|
||||
});
|
||||
|
||||
ipcMain.handle('clipboard:append-video-to-queue', () => {
|
||||
ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => {
|
||||
return deps.appendClipboardVideoToQueue();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user