From 05be13be9e169f24a0c9eac22a254bc93e102932 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 21 Feb 2026 17:02:00 -0800 Subject: [PATCH] refactor(ipc): centralize contracts and validate payloads --- ...t-typing-and-runtime-payload-validation.md | 34 +++- docs/architecture.md | 8 + docs/subagents/INDEX.md | 3 +- ...sk80-ipc-contract-20260222T001728Z-obrv.md | 23 ++- docs/subagents/collaboration.md | 2 + src/core/services/anki-jimaku-ipc.test.ts | 153 ++++++++++++++++ src/core/services/anki-jimaku-ipc.ts | 107 +++++++---- src/core/services/ipc.test.ts | 173 +++++++++++++++++- src/core/services/ipc.ts | 166 +++++++++++------ src/main.ts | 10 +- src/main/dependencies.ts | 18 +- .../composers/ipc-runtime-composer.test.ts | 23 ++- .../runtime/composers/ipc-runtime-composer.ts | 11 +- src/preload.ts | 131 +++++++------ src/shared/ipc/contracts.ts | 86 +++++++++ src/shared/ipc/validators.ts | 157 ++++++++++++++++ 16 files changed, 915 insertions(+), 190 deletions(-) create mode 100644 src/core/services/anki-jimaku-ipc.test.ts create mode 100644 src/shared/ipc/contracts.ts create mode 100644 src/shared/ipc/validators.ts diff --git a/backlog/tasks/task-80 - Strengthen-ipc-contract-typing-and-runtime-payload-validation.md b/backlog/tasks/task-80 - Strengthen-ipc-contract-typing-and-runtime-payload-validation.md index 7aa9c03..d767233 100644 --- a/backlog/tasks/task-80 - Strengthen-ipc-contract-typing-and-runtime-payload-validation.md +++ b/backlog/tasks/task-80 - Strengthen-ipc-contract-typing-and-runtime-payload-validation.md @@ -1,11 +1,11 @@ --- id: TASK-80 title: Strengthen IPC contract typing and runtime payload validation -status: In Progress +status: Done assignee: - opencode-task80-ipc-contract created_date: '2026-02-18 11:43' -updated_date: '2026-02-22 00:21' +updated_date: '2026-02-22 00:56' labels: - ipc - type-safety @@ -41,10 +41,10 @@ IPC handlers still rely on many `unknown` payload casts in main process paths. T ## Acceptance Criteria -- [ ] #1 IPC channels are defined in a typed central contract -- [ ] #2 Runtime payload validation exists for externally supplied IPC data -- [ ] #3 Unsafe cast usage in IPC boundary code is materially reduced -- [ ] #4 Malformed payloads are handled gracefully and test-covered +- [x] #1 IPC channels are defined in a typed central contract +- [x] #2 Runtime payload validation exists for externally supplied IPC data +- [x] #3 Unsafe cast usage in IPC boundary code is materially reduced +- [x] #4 Malformed payloads are handled gracefully and test-covered ## Implementation Plan @@ -65,10 +65,28 @@ Plan of record (2026-02-22): 2026-02-22: Started execution session opencode-task80-ipc-contract-20260222T001728Z-obrv. Loading IPC boundary code and preparing implementation plan via writing-plans before any code edits. Saved plan document: docs/plans/2026-02-22-task-80-ipc-contract-validation.md. Proceeding with executing-plans implementation flow as requested. + +Implemented central IPC contract module (`src/shared/ipc/contracts.ts`) and boundary validators (`src/shared/ipc/validators.ts`). Migrated preload/main IPC registrations from repeated literals to shared contract constants. + +Hardened runtime payload validation at IPC boundaries in `src/core/services/ipc.ts` and `src/core/services/anki-jimaku-ipc.ts` with graceful malformed-payload handling (structured invoke errors or safe no-op for fire-and-forget channels). + +Reduced IPC boundary casts by tightening runtime dependency signatures and wiring (`src/main/dependencies.ts`, `src/main.ts`, `src/main/runtime/composers/ipc-runtime-composer.ts`). + +Added malformed payload regression coverage in `src/core/services/ipc.test.ts` and new `src/core/services/anki-jimaku-ipc.test.ts`; wired dist lane command list in `package.json`. + +Validation run: `bun run build` (pass), `bun run test:core:src` (pass), `bun run test:core:dist` (pass). Updated IPC architecture conventions in `docs/architecture.md`. +## Final Summary + + +Implemented TASK-80 by introducing a centralized IPC contract (`src/shared/ipc/contracts.ts`) and reusable boundary validators (`src/shared/ipc/validators.ts`), then migrating main/preload IPC wiring to those shared definitions. Main-process IPC handlers now validate renderer-supplied payloads before dispatch, returning structured errors for malformed invoke requests and ignoring invalid fire-and-forget payloads safely. + +The runtime boundary typing was tightened to remove several unsafe casts in IPC paths (`src/main.ts`, `src/main/dependencies.ts`, `src/main/runtime/composers/ipc-runtime-composer.ts`) while preserving behavior. Added malformed payload tests for both core IPC and Anki/Jimaku IPC handler surfaces (`src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`), and updated architecture docs with contract/validator ownership and boundary rules (`docs/architecture.md`). Verified with `bun run build`, `bun run test:core:src`, and `bun run test:core:dist` (all passing). + + ## Definition of Done -- [ ] #1 IPC-related tests pass -- [ ] #2 IPC contract docs updated +- [x] #1 IPC-related tests pass +- [x] #2 IPC contract docs updated diff --git a/docs/architecture.md b/docs/architecture.md index 34ee8a0..d48fd94 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -178,6 +178,13 @@ Composer modules share contract conventions via `src/main/runtime/composers/cont This keeps side effects explicit and makes behavior easy to unit-test with fakes. +### IPC Contract + Validation Boundary + +- Central channel constants live in `src/shared/ipc/contracts.ts` and are consumed by both main (`ipcMain`) and renderer preload (`ipcRenderer`) wiring. +- Runtime payload parsers/type guards live in `src/shared/ipc/validators.ts`. +- Rule: renderer-supplied payloads must be validated at IPC entry points (`src/core/services/ipc.ts`, `src/core/services/anki-jimaku-ipc.ts`) before calling domain handlers. +- Malformed invoke payloads return explicit structured errors (for example `{ ok: false, error: ... }`) and malformed fire-and-forget payloads are ignored safely. + ### Runtime State Ownership (Migrated Domains) For domains migrated to reducer-style transitions (for example AniList token/queue/media-guess runtime state), follow these rules: @@ -255,3 +262,4 @@ flowchart TD - Add/update unit tests for each service extraction or behavior change. - For cross-cutting changes, extract-first then refactor internals after parity is verified. - When adding new IPC channels or CLI commands, register them in the appropriate `src/main/` module (`ipc-runtime.ts` for IPC, `cli-runtime.ts` for CLI). +- When adding/changing IPC channels, update `src/shared/ipc/contracts.ts`, validate payloads in `src/shared/ipc/validators.ts`, and add malformed-payload tests. diff --git a/docs/subagents/INDEX.md b/docs/subagents/INDEX.md index 2267aaa..827f7a6 100644 --- a/docs/subagents/INDEX.md +++ b/docs/subagents/INDEX.md @@ -54,6 +54,7 @@ Read first. Keep concise. | `opencode-task77-sliceb-20260221T232507Z-vzk5` | `opencode-task77-sliceb` | `Implement TASK-77 slice B parser-enrichment stage module + focused tests without touching tokenizer.ts` | `done` | `docs/subagents/agents/opencode-task77-sliceb-20260221T232507Z-vzk5.md` | `2026-02-21T23:27:40Z` | | `opencode-task79-runtime-reducers-20260221T235652Z-n4p7` | `opencode-task79-runtime-reducers` | `Execute TASK-79 explicit runtime state transitions/reducers in main via plan-first workflow` | `done` | `docs/subagents/agents/opencode-task79-runtime-reducers-20260221T235652Z-n4p7.md` | `2026-02-22T00:10:51Z` | | `opencode-task79-sliceb-20260222T000253Z-m2r7` | `opencode-task79-sliceb` | `Implement TASK-79 slice B invariants coverage/tests and composition-boundary docs updates without commit` | `done` | `docs/subagents/agents/opencode-task79-sliceb-20260222T000253Z-m2r7.md` | `2026-02-22T00:04:21Z` | -| `opencode-task80-ipc-contract-20260222T001728Z-obrv` | `opencode-task80-ipc-contract` | `Execute TASK-80 IPC contract typing + runtime payload validation end-to-end without commit` | `planning` | `docs/subagents/agents/opencode-task80-ipc-contract-20260222T001728Z-obrv.md` | `2026-02-22T00:17:28Z` | +| `opencode-task80-ipc-contract-20260222T001728Z-obrv` | `opencode-task80-ipc-contract` | `Execute TASK-80 IPC contract typing + runtime payload validation end-to-end without commit` | `done` | `docs/subagents/agents/opencode-task80-ipc-contract-20260222T001728Z-obrv.md` | `2026-02-22T00:56:00Z` | | `opencode-task82-smoke-20260222T002150Z-p5bp` | `opencode-task82-smoke` | `Execute TASK-82 e2e smoke suite for launcher/mpv/ipc/overlay end-to-end without commit` | `done` | `docs/subagents/agents/opencode-task82-smoke-20260222T002150Z-p5bp.md` | `2026-02-22T00:54:29Z` | | `codex-task82-smoke-20260222T002523Z-3j7u` | `codex-task82-smoke` | `Execute TASK-82 e2e smoke suite for launcher/mpv/ipc/overlay end-to-end without commit` | `done` | `docs/subagents/agents/codex-task82-smoke-20260222T002523Z-3j7u.md` | `2026-02-22T00:53:25Z` | +| `opencode-task81-launcher-modules-20260222T005725Z-8oh8` | `opencode-task81-launcher-modules` | `Execute TASK-81 launcher command-module/process-adapter refactor end-to-end without commit` | `in_progress` | `docs/subagents/agents/opencode-task81-launcher-modules-20260222T005725Z-8oh8.md` | `2026-02-22T00:57:25Z` | diff --git a/docs/subagents/agents/opencode-task80-ipc-contract-20260222T001728Z-obrv.md b/docs/subagents/agents/opencode-task80-ipc-contract-20260222T001728Z-obrv.md index 4a9e21b..4584379 100644 --- a/docs/subagents/agents/opencode-task80-ipc-contract-20260222T001728Z-obrv.md +++ b/docs/subagents/agents/opencode-task80-ipc-contract-20260222T001728Z-obrv.md @@ -2,9 +2,9 @@ - alias: `opencode-task80-ipc-contract` - mission: `Execute TASK-80 IPC contract typing + runtime payload validation via writing-plans + executing-plans (no commit).` -- status: `planning` +- status: `done` - started_utc: `2026-02-22T00:17:28Z` -- last_update_utc: `2026-02-22T00:17:28Z` +- last_update_utc: `2026-02-22T00:56:00Z` ## Intent @@ -20,6 +20,22 @@ - `src/main/**/*.test.ts` - `docs/architecture.md` +## Files Touched + +- `src/shared/ipc/contracts.ts` +- `src/shared/ipc/validators.ts` +- `src/core/services/ipc.ts` +- `src/core/services/anki-jimaku-ipc.ts` +- `src/preload.ts` +- `src/main/dependencies.ts` +- `src/main.ts` +- `src/main/runtime/composers/ipc-runtime-composer.ts` +- `src/main/runtime/composers/ipc-runtime-composer.test.ts` +- `src/core/services/ipc.test.ts` +- `src/core/services/anki-jimaku-ipc.test.ts` +- `package.json` +- `docs/architecture.md` + ## Assumptions - TASK-80 scope is main-process IPC boundary; keep external behavior/backward compatibility. @@ -29,3 +45,6 @@ ## Phase Log - `2026-02-22T00:17:28Z` Session started; read backlog overview and TASK-80 details; beginning planning. +- `2026-02-22T00:21:00Z` Plan written to `docs/plans/2026-02-22-task-80-ipc-contract-validation.md` and recorded in Backlog TASK-80. +- `2026-02-22T00:55:30Z` Implemented IPC contract/constants + validators and rewired main/preload/anki-jimaku IPC boundary handlers with runtime payload checks and graceful malformed handling. +- `2026-02-22T00:56:00Z` Validation complete: `bun run build`, `bun run test:core:src`, `bun run test:core:dist` all passing; TASK-80 finalized Done in Backlog. diff --git a/docs/subagents/collaboration.md b/docs/subagents/collaboration.md index f734167..5946436 100644 --- a/docs/subagents/collaboration.md +++ b/docs/subagents/collaboration.md @@ -74,5 +74,7 @@ Shared notes. Append-only. - [2026-02-22T00:17:28Z] [opencode-task80-ipc-contract-20260222T001728Z-obrv|opencode-task80-ipc-contract] starting TASK-80 via Backlog MCP + writing-plans/executing-plans; scope IPC contract typing/runtime payload validation + malformed payload tests; will parallelize independent slices where possible. - [2026-02-22T00:21:50Z] [opencode-task82-smoke-20260222T002150Z-p5bp|opencode-task82-smoke] starting TASK-82 via Backlog MCP + writing-plans/executing-plans; scope e2e smoke suite for launcher mpv ipc overlay startup + workflow/docs wiring, no commit. - [2026-02-22T00:25:23Z] [codex-task82-smoke-20260222T002523Z-3j7u|codex-task82-smoke] overlap note: taking active TASK-82 execution; reusing existing task context/plan artifact, scoping edits to launcher smoke test + workflow/docs wiring + backlog evidence updates only. +- [2026-02-22T00:56:00Z] [opencode-task80-ipc-contract-20260222T001728Z-obrv|opencode-task80-ipc-contract] completed TASK-80: added central IPC contract + boundary validators (`src/shared/ipc/*`), rewired main/preload/anki-jimaku IPC channel usage, added malformed payload tests (`ipc.test.ts`, `anki-jimaku-ipc.test.ts`), docs updated (`docs/architecture.md`), build + core src/dist tests green, and backlog TASK-80 marked Done. - [2026-02-22T00:54:29Z] [opencode-task82-smoke-20260222T002150Z-p5bp|opencode-task82-smoke] completed TASK-82 implementation pass: launcher smoke e2e stabilized (`launcher/smoke.e2e.test.ts`), CI/release smoke + artifact upload wired, docs updated (`docs/development.md`, `docs/installation.md`), and verification lanes green (`test:launcher:smoke:src`, `test:launcher`, `test:fast`, `build`, `test:smoke:dist`, `docs:build`). - [2026-02-22T00:53:25Z] [codex-task82-smoke-20260222T002523Z-3j7u|codex-task82-smoke] completed TASK-82: added `launcher/smoke.e2e.test.ts`, wired `test:launcher:smoke:src` + CI/release smoke gates with `.tmp/launcher-smoke` failure artifact upload, docs updated (`docs/development.md`, `docs/installation.md`), launcher/fast/docs lanes green; `build + test:smoke:dist` still blocked by unrelated TASK-80 IPC typing errors. +- [2026-02-22T00:57:25Z] [opencode-task81-launcher-modules-20260222T005725Z-8oh8|opencode-task81-launcher-modules] starting TASK-81 via Backlog MCP + writing-plans/executing-plans; expected scope `launcher/main.ts` command extraction, process adapters, and launcher tests while preserving CLI behavior/exit codes. diff --git a/src/core/services/anki-jimaku-ipc.test.ts b/src/core/services/anki-jimaku-ipc.test.ts new file mode 100644 index 0000000..9f6afcc --- /dev/null +++ b/src/core/services/anki-jimaku-ipc.test.ts @@ -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 void>; + handleHandlers: Map unknown>; +} { + const onHandlers = new Map void>(); + const handleHandlers = new Map 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, + }, + ]); +}); diff --git a/src/core/services/anki-jimaku-ipc.ts b/src/core/services/anki-jimaku-ipc.ts index 5fad264..6721a7c 100644 --- a/src/core/services/anki-jimaku-ipc.ts +++ b/src/core/services/anki-jimaku-ipc.ts @@ -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 => { + 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 => { - 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> => { - return deps.searchJimakuEntries(query); + ipc.handle( + IPC_CHANNELS.request.jimakuSearchEntries, + async (_event, query: unknown): Promise> => { + 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> => { - return deps.listJimakuFiles(query); + ipc.handle( + IPC_CHANNELS.request.jimakuListFiles, + async (_event, query: unknown): Promise> => { + 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 => { + ipc.handle( + IPC_CHANNELS.request.jimakuDownloadFile, + async (_event, query: unknown): Promise => { + 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', }); diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 961f2a8..88f14cd 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -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 void>; + handle: Map 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']); +}); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index f12f650..77fc5d5 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -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; + runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; 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) => void; getKeybindings: () => unknown; @@ -89,11 +112,11 @@ export interface IpcDepsRuntimeOptions { getSecondarySubMode: () => unknown; getMpvClient: () => MpvClientLike | null; focusMainWindow: () => void; - runSubsyncManual: (request: unknown) => Promise; + runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; 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(); }); } diff --git a/src/main.ts b/src/main.ts index 3e5d627..e5e92be 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2622,8 +2622,7 @@ const { hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null, }, handleMpvCommandFromIpcRuntime, - runSubsyncManualFromIpc: (request) => - subsyncRuntime.runManualFromIpc(request as SubsyncManualRunRequest), + runSubsyncManualFromIpc: (request) => subsyncRuntime.runManualFromIpc(request), registration: { runtimeOptions: { getRuntimeOptionsManager: () => appState.runtimeOptionsManager, @@ -2641,8 +2640,8 @@ const { mainWindow.focus(); } }, - onOverlayModalClosed: (modal: string) => { - handleOverlayModalClosed(modal as OverlayHostedModal); + onOverlayModalClosed: (modal) => { + handleOverlayModalClosed(modal); }, openYomitanSettings: () => openYomitanSettings(), quitApp: () => app.quit(), @@ -2656,8 +2655,7 @@ const { const resolvedConfig = getResolvedConfig(); return resolveSubtitleStyleForRenderer(resolvedConfig); }, - saveSubtitlePosition: (position: unknown) => - saveSubtitlePosition(position as SubtitlePosition), + saveSubtitlePosition: (position) => saveSubtitlePosition(position), getMecabTokenizer: () => appState.mecabTokenizer, getKeybindings: () => appState.keybindings, getConfiguredShortcuts: () => getConfiguredShortcuts(), diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 0ef19d7..04ab53e 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -26,23 +26,17 @@ export interface SubsyncRuntimeDepsParams { } export function createRuntimeOptionsIpcDeps(params: RuntimeOptionsIpcDepsParams): { - 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; } { return { setRuntimeOption: (id, value) => - setRuntimeOptionFromIpcRuntime( - params.getRuntimeOptionsManager(), - id as RuntimeOptionId, - value as RuntimeOptionValue, - (text) => params.showMpvOsd(text), + setRuntimeOptionFromIpcRuntime(params.getRuntimeOptionsManager(), id, value, (text) => + params.showMpvOsd(text), ), cycleRuntimeOption: (id, direction) => - cycleRuntimeOptionFromIpcRuntime( - params.getRuntimeOptionsManager(), - id as RuntimeOptionId, - direction, - (text) => params.showMpvOsd(text), + cycleRuntimeOptionFromIpcRuntime(params.getRuntimeOptionsManager(), id, direction, (text) => + params.showMpvOsd(text), ), }; } diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index 26c6a46..e798290 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -4,6 +4,7 @@ import { composeIpcRuntimeHandlers } from './ipc-runtime-composer'; test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => { let registered = false; + let receivedSourceTrackId: number | null | undefined; const composed = composeIpcRuntimeHandlers({ mpvCommandMainDeps: { @@ -18,10 +19,13 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b hasRuntimeOptionsManager: () => true, }, handleMpvCommandFromIpcRuntime: () => {}, - runSubsyncManualFromIpc: async (request) => ({ - ok: true, - received: (request as { value: number }).value, - }), + runSubsyncManualFromIpc: async (request) => { + receivedSourceTrackId = request.sourceTrackId; + return { + ok: true, + message: 'ok', + }; + }, registration: { runtimeOptions: { getRuntimeOptionsManager: () => null, @@ -92,11 +96,12 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b assert.equal(typeof composed.runSubsyncManualFromIpc, 'function'); assert.equal(typeof composed.registerIpcRuntimeHandlers, 'function'); - const result = (await composed.runSubsyncManualFromIpc({ value: 7 })) as { - ok: boolean; - received: number; - }; - assert.deepEqual(result, { ok: true, received: 7 }); + const result = await composed.runSubsyncManualFromIpc({ + engine: 'alass', + sourceTrackId: 7, + }); + assert.deepEqual(result, { ok: true, message: 'ok' }); + assert.equal(receivedSourceTrackId, 7); composed.registerIpcRuntimeHandlers(); assert.equal(registered, true); diff --git a/src/main/runtime/composers/ipc-runtime-composer.ts b/src/main/runtime/composers/ipc-runtime-composer.ts index bfec586..f0bec72 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.ts @@ -1,4 +1,5 @@ import type { RegisterIpcRuntimeServicesParams } from '../../ipc-runtime'; +import type { SubsyncManualRunRequest, SubsyncResult } from '../../../types'; import { createBuildMpvCommandFromIpcRuntimeMainDepsHandler, createIpcRuntimeHandlers, @@ -11,7 +12,9 @@ type IpcMainDeps = RegisterIpcRuntimeServicesParams['mainDeps']; type IpcMainDepsWithoutHandlers = Omit; type RunSubsyncManual = IpcMainDeps['runSubsyncManual']; -type IpcRuntimeDeps = Parameters>[0]; +type IpcRuntimeDeps = Parameters< + typeof createIpcRuntimeHandlers +>[0]; export type IpcRuntimeComposerOptions = ComposerInputs<{ mpvCommandMainDeps: Parameters[0]; @@ -38,8 +41,8 @@ export function composeIpcRuntimeHandlers( options.mpvCommandMainDeps, )(); const { handleMpvCommandFromIpc, runSubsyncManualFromIpc } = createIpcRuntimeHandlers< - unknown, - unknown + SubsyncManualRunRequest, + SubsyncResult >({ handleMpvCommandFromIpcDeps: { handleMpvCommandFromIpcRuntime: options.handleMpvCommandFromIpcRuntime, @@ -56,7 +59,7 @@ export function composeIpcRuntimeHandlers( mainDeps: { ...options.registration.mainDeps, handleMpvCommand: (command) => handleMpvCommandFromIpc(command), - runSubsyncManual: (request: unknown) => runSubsyncManualFromIpc(request), + runSubsyncManual: (request) => runSubsyncManualFromIpc(request), }, ankiJimakuDeps: options.registration.ankiJimakuDeps, }); diff --git a/src/preload.ts b/src/preload.ts index 3ac0f8e..a51a81c 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -50,6 +50,7 @@ import type { ShortcutsConfig, ConfigHotReloadPayload, } from './types'; +import { IPC_CHANNELS } from './shared/ipc/contracts'; const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer=')); const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length); @@ -61,47 +62,56 @@ const overlayLayer = const electronAPI: ElectronAPI = { getOverlayLayer: () => overlayLayer, onSubtitle: (callback: (data: SubtitleData) => void) => { - ipcRenderer.on('subtitle:set', (_event: IpcRendererEvent, data: SubtitleData) => + ipcRenderer.on(IPC_CHANNELS.event.subtitleSet, (_event: IpcRendererEvent, data: SubtitleData) => callback(data), ); }, onVisibility: (callback: (visible: boolean) => void) => { - ipcRenderer.on('mpv:subVisibility', (_event: IpcRendererEvent, visible: boolean) => - callback(visible), + ipcRenderer.on( + IPC_CHANNELS.event.subtitleVisibility, + (_event: IpcRendererEvent, visible: boolean) => callback(visible), ); }, onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => { ipcRenderer.on( - 'subtitle-position:set', + IPC_CHANNELS.event.subtitlePositionSet, (_event: IpcRendererEvent, position: SubtitlePosition | null) => { callback(position); }, ); }, - getOverlayVisibility: (): Promise => ipcRenderer.invoke('get-overlay-visibility'), - getCurrentSubtitle: (): Promise => ipcRenderer.invoke('get-current-subtitle'), - getCurrentSubtitleRaw: (): Promise => ipcRenderer.invoke('get-current-subtitle-raw'), - getCurrentSubtitleAss: (): Promise => ipcRenderer.invoke('get-current-subtitle-ass'), - getMpvSubtitleRenderMetrics: () => ipcRenderer.invoke('get-mpv-subtitle-render-metrics'), + getOverlayVisibility: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getOverlayVisibility), + getCurrentSubtitle: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitle), + getCurrentSubtitleRaw: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw), + getCurrentSubtitleAss: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss), + getMpvSubtitleRenderMetrics: () => + ipcRenderer.invoke(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics), onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => { ipcRenderer.on( - 'mpv-subtitle-render-metrics:set', + IPC_CHANNELS.event.mpvSubtitleRenderMetricsSet, (_event: IpcRendererEvent, metrics: MpvSubtitleRenderMetrics) => { callback(metrics); }, ); }, onSubtitleAss: (callback: (assText: string) => void) => { - ipcRenderer.on('subtitle-ass:set', (_event: IpcRendererEvent, assText: string) => { - callback(assText); - }); + ipcRenderer.on( + IPC_CHANNELS.event.subtitleAssSet, + (_event: IpcRendererEvent, assText: string) => { + callback(assText); + }, + ); }, onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => { ipcRenderer.on( - 'overlay-debug-visualization:set', + IPC_CHANNELS.event.overlayDebugVisualizationSet, (_event: IpcRendererEvent, enabled: boolean) => { callback(enabled); }, @@ -109,138 +119,147 @@ const electronAPI: ElectronAPI = { }, setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { - ipcRenderer.send('set-ignore-mouse-events', ignore, options); + ipcRenderer.send(IPC_CHANNELS.command.setIgnoreMouseEvents, ignore, options); }, openYomitanSettings: () => { - ipcRenderer.send('open-yomitan-settings'); + ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings); }, getSubtitlePosition: (): Promise => - ipcRenderer.invoke('get-subtitle-position'), + ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitlePosition), saveSubtitlePosition: (position: SubtitlePosition) => { - ipcRenderer.send('save-subtitle-position', position); + ipcRenderer.send(IPC_CHANNELS.command.saveSubtitlePosition, position); }, - getMecabStatus: (): Promise => ipcRenderer.invoke('get-mecab-status'), + getMecabStatus: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getMecabStatus), setMecabEnabled: (enabled: boolean) => { - ipcRenderer.send('set-mecab-enabled', enabled); + ipcRenderer.send(IPC_CHANNELS.command.setMecabEnabled, enabled); }, sendMpvCommand: (command: (string | number)[]) => { - ipcRenderer.send('mpv-command', command); + ipcRenderer.send(IPC_CHANNELS.command.mpvCommand, command); }, - getKeybindings: (): Promise => ipcRenderer.invoke('get-keybindings'), + getKeybindings: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings), getConfiguredShortcuts: (): Promise> => - ipcRenderer.invoke('get-config-shortcuts'), + ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), - getJimakuMediaInfo: (): Promise => ipcRenderer.invoke('jimaku:get-media-info'), + getJimakuMediaInfo: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo), jimakuSearchEntries: (query: JimakuSearchQuery): Promise> => - ipcRenderer.invoke('jimaku:search-entries', query), + ipcRenderer.invoke(IPC_CHANNELS.request.jimakuSearchEntries, query), jimakuListFiles: (query: JimakuFilesQuery): Promise> => - ipcRenderer.invoke('jimaku:list-files', query), + ipcRenderer.invoke(IPC_CHANNELS.request.jimakuListFiles, query), jimakuDownloadFile: (query: JimakuDownloadQuery): Promise => - ipcRenderer.invoke('jimaku:download-file', query), + ipcRenderer.invoke(IPC_CHANNELS.request.jimakuDownloadFile, query), quitApp: () => { - ipcRenderer.send('quit-app'); + ipcRenderer.send(IPC_CHANNELS.command.quitApp); }, toggleDevTools: () => { - ipcRenderer.send('toggle-dev-tools'); + ipcRenderer.send(IPC_CHANNELS.command.toggleDevTools); }, toggleOverlay: () => { - ipcRenderer.send('toggle-overlay'); + ipcRenderer.send(IPC_CHANNELS.command.toggleOverlay); }, - getAnkiConnectStatus: (): Promise => ipcRenderer.invoke('get-anki-connect-status'), + getAnkiConnectStatus: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getAnkiConnectStatus), setAnkiConnectEnabled: (enabled: boolean) => { - ipcRenderer.send('set-anki-connect-enabled', enabled); + ipcRenderer.send(IPC_CHANNELS.command.setAnkiConnectEnabled, enabled); }, clearAnkiConnectHistory: () => { - ipcRenderer.send('clear-anki-connect-history'); + ipcRenderer.send(IPC_CHANNELS.command.clearAnkiConnectHistory); }, onSecondarySub: (callback: (text: string) => void) => { - ipcRenderer.on('secondary-subtitle:set', (_event: IpcRendererEvent, text: string) => - callback(text), + ipcRenderer.on( + IPC_CHANNELS.event.secondarySubtitleSet, + (_event: IpcRendererEvent, text: string) => callback(text), ); }, onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => { - ipcRenderer.on('secondary-subtitle:mode', (_event: IpcRendererEvent, mode: SecondarySubMode) => - callback(mode), + ipcRenderer.on( + IPC_CHANNELS.event.secondarySubtitleMode, + (_event: IpcRendererEvent, mode: SecondarySubMode) => callback(mode), ); }, getSecondarySubMode: (): Promise => - ipcRenderer.invoke('get-secondary-sub-mode'), - getCurrentSecondarySub: (): Promise => ipcRenderer.invoke('get-current-secondary-sub'), - focusMainWindow: () => ipcRenderer.invoke('focus-main-window') as Promise, + ipcRenderer.invoke(IPC_CHANNELS.request.getSecondarySubMode), + getCurrentSecondarySub: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSecondarySub), + focusMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.request.focusMainWindow) as Promise, getSubtitleStyle: (): Promise => - ipcRenderer.invoke('get-subtitle-style'), + ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle), onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => { ipcRenderer.on( - 'subsync:open-manual', + IPC_CHANNELS.event.subsyncOpenManual, (_event: IpcRendererEvent, payload: SubsyncManualPayload) => { callback(payload); }, ); }, runSubsyncManual: (request: SubsyncManualRunRequest): Promise => - ipcRenderer.invoke('subsync:run-manual', request), + ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request), onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => { ipcRenderer.on( - 'kiku:field-grouping-request', + IPC_CHANNELS.event.kikuFieldGroupingRequest, (_event: IpcRendererEvent, data: KikuFieldGroupingRequestData) => callback(data), ); }, kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise => - ipcRenderer.invoke('kiku:build-merge-preview', request), + ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request), kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => { - ipcRenderer.send('kiku:field-grouping-respond', choice); + ipcRenderer.send(IPC_CHANNELS.command.kikuFieldGroupingRespond, choice); }, - getRuntimeOptions: (): Promise => ipcRenderer.invoke('runtime-options:get'), + getRuntimeOptions: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getRuntimeOptions), setRuntimeOptionValue: ( id: RuntimeOptionId, value: RuntimeOptionValue, - ): Promise => ipcRenderer.invoke('runtime-options:set', id, value), + ): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.setRuntimeOption, id, value), cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1): Promise => - ipcRenderer.invoke('runtime-options:cycle', id, direction), + ipcRenderer.invoke(IPC_CHANNELS.request.cycleRuntimeOption, id, direction), onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => { ipcRenderer.on( - 'runtime-options:changed', + IPC_CHANNELS.event.runtimeOptionsChanged, (_event: IpcRendererEvent, options: RuntimeOptionState[]) => { callback(options); }, ); }, onOpenRuntimeOptions: (callback: () => void) => { - ipcRenderer.on('runtime-options:open', () => { + ipcRenderer.on(IPC_CHANNELS.event.runtimeOptionsOpen, () => { callback(); }); }, onOpenJimaku: (callback: () => void) => { - ipcRenderer.on('jimaku:open', () => { + ipcRenderer.on(IPC_CHANNELS.event.jimakuOpen, () => { callback(); }); }, appendClipboardVideoToQueue: (): Promise => - ipcRenderer.invoke('clipboard:append-video-to-queue'), + ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => { - ipcRenderer.send('overlay:modal-closed', modal); + ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); }, reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => { - ipcRenderer.send('overlay-content-bounds:report', measurement); + ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement); }, onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => { ipcRenderer.on( - 'config:hot-reload', + IPC_CHANNELS.event.configHotReload, (_event: IpcRendererEvent, payload: ConfigHotReloadPayload) => { callback(payload); }, diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts new file mode 100644 index 0000000..21531f3 --- /dev/null +++ b/src/shared/ipc/contracts.ts @@ -0,0 +1,86 @@ +import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types'; + +export const OVERLAY_HOSTED_MODALS = ['runtime-options', 'subsync', 'jimaku'] as const; +export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number]; + +export const IPC_CHANNELS = { + command: { + setIgnoreMouseEvents: 'set-ignore-mouse-events', + overlayModalClosed: 'overlay:modal-closed', + openYomitanSettings: 'open-yomitan-settings', + quitApp: 'quit-app', + toggleDevTools: 'toggle-dev-tools', + toggleOverlay: 'toggle-overlay', + saveSubtitlePosition: 'save-subtitle-position', + setMecabEnabled: 'set-mecab-enabled', + mpvCommand: 'mpv-command', + setAnkiConnectEnabled: 'set-anki-connect-enabled', + clearAnkiConnectHistory: 'clear-anki-connect-history', + refreshKnownWords: 'anki:refresh-known-words', + kikuFieldGroupingRespond: 'kiku:field-grouping-respond', + reportOverlayContentBounds: 'overlay-content-bounds:report', + }, + request: { + getOverlayVisibility: 'get-overlay-visibility', + getVisibleOverlayVisibility: 'get-visible-overlay-visibility', + getInvisibleOverlayVisibility: 'get-invisible-overlay-visibility', + getCurrentSubtitle: 'get-current-subtitle', + getCurrentSubtitleRaw: 'get-current-subtitle-raw', + getCurrentSubtitleAss: 'get-current-subtitle-ass', + getMpvSubtitleRenderMetrics: 'get-mpv-subtitle-render-metrics', + getSubtitlePosition: 'get-subtitle-position', + getSubtitleStyle: 'get-subtitle-style', + getMecabStatus: 'get-mecab-status', + getKeybindings: 'get-keybindings', + getConfigShortcuts: 'get-config-shortcuts', + getSecondarySubMode: 'get-secondary-sub-mode', + getCurrentSecondarySub: 'get-current-secondary-sub', + focusMainWindow: 'focus-main-window', + runSubsyncManual: 'subsync:run-manual', + getAnkiConnectStatus: 'get-anki-connect-status', + getRuntimeOptions: 'runtime-options:get', + setRuntimeOption: 'runtime-options:set', + cycleRuntimeOption: 'runtime-options:cycle', + getAnilistStatus: 'anilist:get-status', + clearAnilistToken: 'anilist:clear-token', + openAnilistSetup: 'anilist:open-setup', + getAnilistQueueStatus: 'anilist:get-queue-status', + retryAnilistNow: 'anilist:retry-now', + appendClipboardVideoToQueue: 'clipboard:append-video-to-queue', + jimakuGetMediaInfo: 'jimaku:get-media-info', + jimakuSearchEntries: 'jimaku:search-entries', + jimakuListFiles: 'jimaku:list-files', + jimakuDownloadFile: 'jimaku:download-file', + kikuBuildMergePreview: 'kiku:build-merge-preview', + }, + event: { + subtitleSet: 'subtitle:set', + subtitleVisibility: 'mpv:subVisibility', + subtitlePositionSet: 'subtitle-position:set', + mpvSubtitleRenderMetricsSet: 'mpv-subtitle-render-metrics:set', + subtitleAssSet: 'subtitle-ass:set', + overlayDebugVisualizationSet: 'overlay-debug-visualization:set', + secondarySubtitleSet: 'secondary-subtitle:set', + secondarySubtitleMode: 'secondary-subtitle:mode', + subsyncOpenManual: 'subsync:open-manual', + kikuFieldGroupingRequest: 'kiku:field-grouping-request', + runtimeOptionsChanged: 'runtime-options:changed', + runtimeOptionsOpen: 'runtime-options:open', + jimakuOpen: 'jimaku:open', + configHotReload: 'config:hot-reload', + }, +} as const; + +export type RuntimeOptionsSetRequest = { + id: RuntimeOptionId; + value: RuntimeOptionValue; +}; + +export type RuntimeOptionsCycleRequest = { + id: RuntimeOptionId; + direction: 1 | -1; +}; + +export type OverlayContentBoundsReportRequest = { + measurement: OverlayContentMeasurement; +}; diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts new file mode 100644 index 0000000..1ee1984 --- /dev/null +++ b/src/shared/ipc/validators.ts @@ -0,0 +1,157 @@ +import type { + JimakuDownloadQuery, + JimakuFilesQuery, + JimakuSearchQuery, + KikuFieldGroupingChoice, + KikuMergePreviewRequest, + RuntimeOptionId, + RuntimeOptionValue, + SubtitlePosition, + SubsyncManualRunRequest, +} from '../../types'; +import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts'; + +const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [ + 'anki.autoUpdateNewCards', + 'anki.kikuFieldGrouping', + 'anki.nPlusOneMatchMode', +]; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value); +} + +export function parseOverlayHostedModal(value: unknown): OverlayHostedModal | null { + if (typeof value !== 'string') return null; + return OVERLAY_HOSTED_MODALS.includes(value as OverlayHostedModal) + ? (value as OverlayHostedModal) + : null; +} + +export function parseSubtitlePosition(value: unknown): SubtitlePosition | null { + if (!isObject(value) || !isFiniteNumber(value.yPercent)) { + return null; + } + const hasX = value.invisibleOffsetXPx !== undefined; + if (hasX && !isFiniteNumber(value.invisibleOffsetXPx)) { + return null; + } + const hasY = value.invisibleOffsetYPx !== undefined; + if (hasY && !isFiniteNumber(value.invisibleOffsetYPx)) { + return null; + } + return { + yPercent: value.yPercent, + invisibleOffsetXPx: hasX ? (value.invisibleOffsetXPx as number) : undefined, + invisibleOffsetYPx: hasY ? (value.invisibleOffsetYPx as number) : undefined, + }; +} + +export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null { + if (!isObject(value)) return null; + const { engine, sourceTrackId } = value; + if (engine !== 'alass' && engine !== 'ffsubsync') return null; + if (sourceTrackId !== undefined && sourceTrackId !== null && !isInteger(sourceTrackId)) { + return null; + } + return { + engine, + sourceTrackId: sourceTrackId === undefined ? undefined : (sourceTrackId as number | null), + }; +} + +export function parseRuntimeOptionId(value: unknown): RuntimeOptionId | null { + if (typeof value !== 'string') return null; + return RUNTIME_OPTION_IDS.includes(value as RuntimeOptionId) ? (value as RuntimeOptionId) : null; +} + +export function parseRuntimeOptionDirection(value: unknown): 1 | -1 | null { + return value === 1 || value === -1 ? value : null; +} + +export function parseRuntimeOptionValue(value: unknown): RuntimeOptionValue | null { + return typeof value === 'boolean' || typeof value === 'string' + ? (value as RuntimeOptionValue) + : null; +} + +export function parseMpvCommand(value: unknown): Array | null { + if (!Array.isArray(value)) return null; + return value.every((entry) => typeof entry === 'string' || typeof entry === 'number') + ? (value as Array) + : null; +} + +export function parseOptionalForwardingOptions(value: unknown): { + forward?: boolean; +} { + if (!isObject(value)) return {}; + const { forward } = value; + if (forward === undefined) return {}; + return typeof forward === 'boolean' ? { forward } : {}; +} + +export function parseKikuFieldGroupingChoice(value: unknown): KikuFieldGroupingChoice | null { + if (!isObject(value)) return null; + const { keepNoteId, deleteNoteId, deleteDuplicate, cancelled } = value; + if (!isInteger(keepNoteId) || !isInteger(deleteNoteId)) return null; + if (typeof deleteDuplicate !== 'boolean' || typeof cancelled !== 'boolean') return null; + return { + keepNoteId, + deleteNoteId, + deleteDuplicate, + cancelled, + }; +} + +export function parseKikuMergePreviewRequest(value: unknown): KikuMergePreviewRequest | null { + if (!isObject(value)) return null; + const { keepNoteId, deleteNoteId, deleteDuplicate } = value; + if (!isInteger(keepNoteId) || !isInteger(deleteNoteId)) return null; + if (typeof deleteDuplicate !== 'boolean') return null; + return { + keepNoteId, + deleteNoteId, + deleteDuplicate, + }; +} + +export function parseJimakuSearchQuery(value: unknown): JimakuSearchQuery | null { + if (!isObject(value) || typeof value.query !== 'string') return null; + return { query: value.query }; +} + +export function parseJimakuFilesQuery(value: unknown): JimakuFilesQuery | null { + if (!isObject(value) || !isInteger(value.entryId)) return null; + if (value.episode !== undefined && value.episode !== null && !isInteger(value.episode)) { + return null; + } + return { + entryId: value.entryId, + episode: (value.episode as number | null | undefined) ?? undefined, + }; +} + +export function parseJimakuDownloadQuery(value: unknown): JimakuDownloadQuery | null { + if (!isObject(value)) return null; + if ( + !isInteger(value.entryId) || + typeof value.url !== 'string' || + typeof value.name !== 'string' + ) { + return null; + } + return { + entryId: value.entryId, + url: value.url, + name: value.name, + }; +}