mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor(ipc): centralize contracts and validate payloads
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
id: TASK-80
|
id: TASK-80
|
||||||
title: Strengthen IPC contract typing and runtime payload validation
|
title: Strengthen IPC contract typing and runtime payload validation
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee:
|
assignee:
|
||||||
- opencode-task80-ipc-contract
|
- opencode-task80-ipc-contract
|
||||||
created_date: '2026-02-18 11:43'
|
created_date: '2026-02-18 11:43'
|
||||||
updated_date: '2026-02-22 00:21'
|
updated_date: '2026-02-22 00:56'
|
||||||
labels:
|
labels:
|
||||||
- ipc
|
- ipc
|
||||||
- type-safety
|
- type-safety
|
||||||
@@ -41,10 +41,10 @@ IPC handlers still rely on many `unknown` payload casts in main process paths. T
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 IPC channels are defined in a typed central contract
|
- [x] #1 IPC channels are defined in a typed central contract
|
||||||
- [ ] #2 Runtime payload validation exists for externally supplied IPC data
|
- [x] #2 Runtime payload validation exists for externally supplied IPC data
|
||||||
- [ ] #3 Unsafe cast usage in IPC boundary code is materially reduced
|
- [x] #3 Unsafe cast usage in IPC boundary code is materially reduced
|
||||||
- [ ] #4 Malformed payloads are handled gracefully and test-covered
|
- [x] #4 Malformed payloads are handled gracefully and test-covered
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## 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.
|
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.
|
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`.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
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).
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
<!-- DOD:BEGIN -->
|
<!-- DOD:BEGIN -->
|
||||||
- [ ] #1 IPC-related tests pass
|
- [x] #1 IPC-related tests pass
|
||||||
- [ ] #2 IPC contract docs updated
|
- [x] #2 IPC contract docs updated
|
||||||
<!-- DOD:END -->
|
<!-- DOD:END -->
|
||||||
|
|||||||
@@ -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.
|
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)
|
### Runtime State Ownership (Migrated Domains)
|
||||||
|
|
||||||
For domains migrated to reducer-style transitions (for example AniList token/queue/media-guess runtime state), follow these rules:
|
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.
|
- Add/update unit tests for each service extraction or behavior change.
|
||||||
- For cross-cutting changes, extract-first then refactor internals after parity is verified.
|
- 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 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.
|
||||||
|
|||||||
@@ -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-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-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-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` |
|
| `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` |
|
| `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` |
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
- alias: `opencode-task80-ipc-contract`
|
- alias: `opencode-task80-ipc-contract`
|
||||||
- mission: `Execute TASK-80 IPC contract typing + runtime payload validation via writing-plans + executing-plans (no commit).`
|
- 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`
|
- started_utc: `2026-02-22T00:17:28Z`
|
||||||
- last_update_utc: `2026-02-22T00:17:28Z`
|
- last_update_utc: `2026-02-22T00:56:00Z`
|
||||||
|
|
||||||
## Intent
|
## Intent
|
||||||
|
|
||||||
@@ -20,6 +20,22 @@
|
|||||||
- `src/main/**/*.test.ts`
|
- `src/main/**/*.test.ts`
|
||||||
- `docs/architecture.md`
|
- `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
|
## Assumptions
|
||||||
|
|
||||||
- TASK-80 scope is main-process IPC boundary; keep external behavior/backward compatibility.
|
- TASK-80 scope is main-process IPC boundary; keep external behavior/backward compatibility.
|
||||||
@@ -29,3 +45,6 @@
|
|||||||
## Phase Log
|
## Phase Log
|
||||||
|
|
||||||
- `2026-02-22T00:17:28Z` Session started; read backlog overview and TASK-80 details; beginning planning.
|
- `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.
|
||||||
|
|||||||
@@ -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: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: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: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: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: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.
|
||||||
|
|||||||
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 fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
@@ -16,6 +16,14 @@ import {
|
|||||||
KikuMergePreviewRequest,
|
KikuMergePreviewRequest,
|
||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
} from '../../types';
|
} 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');
|
const logger = createLogger('main:anki-jimaku-ipc');
|
||||||
|
|
||||||
@@ -39,54 +47,85 @@ export interface AnkiJimakuIpcDeps {
|
|||||||
onDownloadedSubtitle: (pathToSubtitle: string) => void;
|
onDownloadedSubtitle: (pathToSubtitle: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerAnkiJimakuIpcHandlers(deps: AnkiJimakuIpcDeps): void {
|
interface IpcMainRegistrar {
|
||||||
ipcMain.on('set-anki-connect-enabled', (_event: IpcMainEvent, enabled: boolean) => {
|
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);
|
deps.setAnkiConnectEnabled(enabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('clear-anki-connect-history', () => {
|
ipc.on(IPC_CHANNELS.command.clearAnkiConnectHistory, () => {
|
||||||
deps.clearAnkiHistory();
|
deps.clearAnkiHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('anki:refresh-known-words', async () => {
|
ipc.on(IPC_CHANNELS.command.refreshKnownWords, async () => {
|
||||||
await deps.refreshKnownWords();
|
await deps.refreshKnownWords();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on(
|
ipc.on(IPC_CHANNELS.command.kikuFieldGroupingRespond, (_event: unknown, choice: unknown) => {
|
||||||
'kiku:field-grouping-respond',
|
const parsedChoice = parseKikuFieldGroupingChoice(choice);
|
||||||
(_event: IpcMainEvent, choice: KikuFieldGroupingChoice) => {
|
if (!parsedChoice) return;
|
||||||
deps.respondFieldGrouping(choice);
|
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(
|
ipc.handle(IPC_CHANNELS.request.jimakuGetMediaInfo, (): JimakuMediaInfo => {
|
||||||
'kiku:build-merge-preview',
|
|
||||||
async (_event, request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> => {
|
|
||||||
return deps.buildKikuMergePreview(request);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ipcMain.handle('jimaku:get-media-info', (): JimakuMediaInfo => {
|
|
||||||
return deps.getJimakuMediaInfo();
|
return deps.getJimakuMediaInfo();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(
|
ipc.handle(
|
||||||
'jimaku:search-entries',
|
IPC_CHANNELS.request.jimakuSearchEntries,
|
||||||
async (_event, query: JimakuSearchQuery): Promise<JimakuApiResponse<JimakuEntry[]>> => {
|
async (_event, query: unknown): Promise<JimakuApiResponse<JimakuEntry[]>> => {
|
||||||
return deps.searchJimakuEntries(query);
|
const parsedQuery = parseJimakuSearchQuery(query);
|
||||||
|
if (!parsedQuery) {
|
||||||
|
return { ok: false, error: { error: 'Invalid Jimaku search query payload', code: 400 } };
|
||||||
|
}
|
||||||
|
return deps.searchJimakuEntries(parsedQuery);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle(
|
ipc.handle(
|
||||||
'jimaku:list-files',
|
IPC_CHANNELS.request.jimakuListFiles,
|
||||||
async (_event, query: JimakuFilesQuery): Promise<JimakuApiResponse<JimakuFileEntry[]>> => {
|
async (_event, query: unknown): Promise<JimakuApiResponse<JimakuFileEntry[]>> => {
|
||||||
return deps.listJimakuFiles(query);
|
const parsedQuery = parseJimakuFilesQuery(query);
|
||||||
|
if (!parsedQuery) {
|
||||||
|
return { ok: false, error: { error: 'Invalid Jimaku files query payload', code: 400 } };
|
||||||
|
}
|
||||||
|
return deps.listJimakuFiles(parsedQuery);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle(
|
ipc.handle(
|
||||||
'jimaku:download-file',
|
IPC_CHANNELS.request.jimakuDownloadFile,
|
||||||
async (_event, query: JimakuDownloadQuery): Promise<JimakuDownloadResult> => {
|
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();
|
const apiKey = await deps.resolveJimakuApiKey();
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return {
|
return {
|
||||||
@@ -106,7 +145,7 @@ export function registerAnkiJimakuIpcHandlers(deps: AnkiJimakuIpcDeps): void {
|
|||||||
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
|
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
|
||||||
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jimaku-'))
|
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jimaku-'))
|
||||||
: path.dirname(path.resolve(currentMediaPath));
|
: path.dirname(path.resolve(currentMediaPath));
|
||||||
const safeName = path.basename(query.name);
|
const safeName = path.basename(parsedQuery.name);
|
||||||
if (!safeName) {
|
if (!safeName) {
|
||||||
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
|
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;
|
const baseName = ext ? safeName.slice(0, -ext.length) : safeName;
|
||||||
let targetPath = path.join(mediaDir, safeName);
|
let targetPath = path.join(mediaDir, safeName);
|
||||||
if (fs.existsSync(targetPath)) {
|
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;
|
let counter = 2;
|
||||||
while (fs.existsSync(targetPath)) {
|
while (fs.existsSync(targetPath)) {
|
||||||
targetPath = path.join(
|
targetPath = path.join(
|
||||||
mediaDir,
|
mediaDir,
|
||||||
`${baseName} (jimaku-${query.entryId}-${counter})${ext}`,
|
`${baseName} (jimaku-${parsedQuery.entryId}-${counter})${ext}`,
|
||||||
);
|
);
|
||||||
counter += 1;
|
counter += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[jimaku] download-file name="${query.name}" entryId=${query.entryId}`);
|
logger.info(
|
||||||
const result = await deps.downloadToFile(query.url, targetPath, {
|
`[jimaku] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
|
||||||
|
);
|
||||||
|
const result = await deps.downloadToFile(parsedQuery.url, targetPath, {
|
||||||
Authorization: apiKey,
|
Authorization: apiKey,
|
||||||
'User-Agent': 'SubMiner',
|
'User-Agent': 'SubMiner',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 () => {
|
test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
@@ -28,7 +58,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
getSecondarySubMode: () => 'hover',
|
getSecondarySubMode: () => 'hover',
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
focusMainWindow: () => {},
|
focusMainWindow: () => {},
|
||||||
runSubsyncManual: async () => ({}),
|
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||||
getAnkiConnectStatus: () => false,
|
getAnkiConnectStatus: () => false,
|
||||||
getRuntimeOptions: () => ({}),
|
getRuntimeOptions: () => ({}),
|
||||||
setRuntimeOption: () => ({ ok: true }),
|
setRuntimeOption: () => ({ ok: true }),
|
||||||
@@ -63,3 +93,142 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
});
|
});
|
||||||
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
|
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 { 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 {
|
export interface IpcServiceDeps {
|
||||||
getInvisibleWindow: () => WindowLike | null;
|
getInvisibleWindow: () => WindowLike | null;
|
||||||
isVisibleOverlayVisible: () => boolean;
|
isVisibleOverlayVisible: () => boolean;
|
||||||
setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
setInvisibleIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||||
onOverlayModalClosed: (modal: string) => void;
|
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
toggleDevTools: () => void;
|
toggleDevTools: () => void;
|
||||||
@@ -17,7 +35,7 @@ export interface IpcServiceDeps {
|
|||||||
getMpvSubtitleRenderMetrics: () => unknown;
|
getMpvSubtitleRenderMetrics: () => unknown;
|
||||||
getSubtitlePosition: () => unknown;
|
getSubtitlePosition: () => unknown;
|
||||||
getSubtitleStyle: () => unknown;
|
getSubtitleStyle: () => unknown;
|
||||||
saveSubtitlePosition: (position: unknown) => void;
|
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||||
getMecabStatus: () => {
|
getMecabStatus: () => {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -30,11 +48,11 @@ export interface IpcServiceDeps {
|
|||||||
getSecondarySubMode: () => unknown;
|
getSecondarySubMode: () => unknown;
|
||||||
getCurrentSecondarySub: () => string;
|
getCurrentSecondarySub: () => string;
|
||||||
focusMainWindow: () => void;
|
focusMainWindow: () => void;
|
||||||
runSubsyncManual: (request: unknown) => Promise<unknown>;
|
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||||
getAnkiConnectStatus: () => boolean;
|
getAnkiConnectStatus: () => boolean;
|
||||||
getRuntimeOptions: () => unknown;
|
getRuntimeOptions: () => unknown;
|
||||||
setRuntimeOption: (id: string, value: unknown) => unknown;
|
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||||
cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||||
reportOverlayContentBounds: (payload: unknown) => void;
|
reportOverlayContentBounds: (payload: unknown) => void;
|
||||||
getAnilistStatus: () => unknown;
|
getAnilistStatus: () => unknown;
|
||||||
clearAnilistToken: () => void;
|
clearAnilistToken: () => void;
|
||||||
@@ -66,12 +84,17 @@ interface MpvClientLike {
|
|||||||
currentSecondarySubText?: string;
|
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 {
|
export interface IpcDepsRuntimeOptions {
|
||||||
getInvisibleWindow: () => WindowLike | null;
|
getInvisibleWindow: () => WindowLike | null;
|
||||||
getMainWindow: () => WindowLike | null;
|
getMainWindow: () => WindowLike | null;
|
||||||
getVisibleOverlayVisibility: () => boolean;
|
getVisibleOverlayVisibility: () => boolean;
|
||||||
getInvisibleOverlayVisibility: () => boolean;
|
getInvisibleOverlayVisibility: () => boolean;
|
||||||
onOverlayModalClosed: (modal: string) => void;
|
onOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
@@ -81,7 +104,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
getMpvSubtitleRenderMetrics: () => unknown;
|
getMpvSubtitleRenderMetrics: () => unknown;
|
||||||
getSubtitlePosition: () => unknown;
|
getSubtitlePosition: () => unknown;
|
||||||
getSubtitleStyle: () => unknown;
|
getSubtitleStyle: () => unknown;
|
||||||
saveSubtitlePosition: (position: unknown) => void;
|
saveSubtitlePosition: (position: SubtitlePosition) => void;
|
||||||
getMecabTokenizer: () => MecabTokenizerLike | null;
|
getMecabTokenizer: () => MecabTokenizerLike | null;
|
||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
@@ -89,11 +112,11 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
getSecondarySubMode: () => unknown;
|
getSecondarySubMode: () => unknown;
|
||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
focusMainWindow: () => void;
|
focusMainWindow: () => void;
|
||||||
runSubsyncManual: (request: unknown) => Promise<unknown>;
|
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||||
getAnkiConnectStatus: () => boolean;
|
getAnkiConnectStatus: () => boolean;
|
||||||
getRuntimeOptions: () => unknown;
|
getRuntimeOptions: () => unknown;
|
||||||
setRuntimeOption: (id: string, value: unknown) => unknown;
|
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||||
cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||||
reportOverlayContentBounds: (payload: unknown) => void;
|
reportOverlayContentBounds: (payload: unknown) => void;
|
||||||
getAnilistStatus: () => unknown;
|
getAnilistStatus: () => unknown;
|
||||||
clearAnilistToken: () => void;
|
clearAnilistToken: () => void;
|
||||||
@@ -166,11 +189,13 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerIpcHandlers(deps: IpcServiceDeps): void {
|
export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar = ipcMain): void {
|
||||||
ipcMain.on(
|
ipc.on(
|
||||||
'set-ignore-mouse-events',
|
IPC_CHANNELS.command.setIgnoreMouseEvents,
|
||||||
(event: IpcMainEvent, ignore: boolean, options: { forward?: boolean } = {}) => {
|
(event: unknown, ignore: unknown, options: unknown = {}) => {
|
||||||
const senderWindow = BrowserWindow.fromWebContents(event.sender);
|
if (typeof ignore !== 'boolean') return;
|
||||||
|
const parsedOptions = parseOptionalForwardingOptions(options);
|
||||||
|
const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender);
|
||||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||||
const invisibleWindow = deps.getInvisibleWindow();
|
const invisibleWindow = deps.getInvisibleWindow();
|
||||||
if (
|
if (
|
||||||
@@ -181,151 +206,178 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void {
|
|||||||
) {
|
) {
|
||||||
deps.setInvisibleIgnoreMouseEvents(true, { forward: true });
|
deps.setInvisibleIgnoreMouseEvents(true, { forward: true });
|
||||||
} else {
|
} else {
|
||||||
senderWindow.setIgnoreMouseEvents(ignore, options);
|
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.on('overlay:modal-closed', (_event: IpcMainEvent, modal: string) => {
|
ipc.on(IPC_CHANNELS.command.overlayModalClosed, (_event: unknown, modal: unknown) => {
|
||||||
deps.onOverlayModalClosed(modal);
|
const parsedModal = parseOverlayHostedModal(modal);
|
||||||
|
if (!parsedModal) return;
|
||||||
|
deps.onOverlayModalClosed(parsedModal);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('open-yomitan-settings', () => {
|
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
|
||||||
deps.openYomitanSettings();
|
deps.openYomitanSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('quit-app', () => {
|
ipc.on(IPC_CHANNELS.command.quitApp, () => {
|
||||||
deps.quitApp();
|
deps.quitApp();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('toggle-dev-tools', () => {
|
ipc.on(IPC_CHANNELS.command.toggleDevTools, () => {
|
||||||
deps.toggleDevTools();
|
deps.toggleDevTools();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-overlay-visibility', () => {
|
ipc.handle(IPC_CHANNELS.request.getOverlayVisibility, () => {
|
||||||
return deps.getVisibleOverlayVisibility();
|
return deps.getVisibleOverlayVisibility();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('toggle-overlay', () => {
|
ipc.on(IPC_CHANNELS.command.toggleOverlay, () => {
|
||||||
deps.toggleVisibleOverlay();
|
deps.toggleVisibleOverlay();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-visible-overlay-visibility', () => {
|
ipc.handle(IPC_CHANNELS.request.getVisibleOverlayVisibility, () => {
|
||||||
return deps.getVisibleOverlayVisibility();
|
return deps.getVisibleOverlayVisibility();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-invisible-overlay-visibility', () => {
|
ipc.handle(IPC_CHANNELS.request.getInvisibleOverlayVisibility, () => {
|
||||||
return deps.getInvisibleOverlayVisibility();
|
return deps.getInvisibleOverlayVisibility();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-current-subtitle', async () => {
|
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitle, async () => {
|
||||||
return await deps.tokenizeCurrentSubtitle();
|
return await deps.tokenizeCurrentSubtitle();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-current-subtitle-raw', () => {
|
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitleRaw, () => {
|
||||||
return deps.getCurrentSubtitleRaw();
|
return deps.getCurrentSubtitleRaw();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-current-subtitle-ass', () => {
|
ipc.handle(IPC_CHANNELS.request.getCurrentSubtitleAss, () => {
|
||||||
return deps.getCurrentSubtitleAss();
|
return deps.getCurrentSubtitleAss();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-mpv-subtitle-render-metrics', () => {
|
ipc.handle(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics, () => {
|
||||||
return deps.getMpvSubtitleRenderMetrics();
|
return deps.getMpvSubtitleRenderMetrics();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-subtitle-position', () => {
|
ipc.handle(IPC_CHANNELS.request.getSubtitlePosition, () => {
|
||||||
return deps.getSubtitlePosition();
|
return deps.getSubtitlePosition();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-subtitle-style', () => {
|
ipc.handle(IPC_CHANNELS.request.getSubtitleStyle, () => {
|
||||||
return deps.getSubtitleStyle();
|
return deps.getSubtitleStyle();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('save-subtitle-position', (_event: IpcMainEvent, position: unknown) => {
|
ipc.on(IPC_CHANNELS.command.saveSubtitlePosition, (_event: unknown, position: unknown) => {
|
||||||
deps.saveSubtitlePosition(position);
|
const parsedPosition = parseSubtitlePosition(position);
|
||||||
|
if (!parsedPosition) return;
|
||||||
|
deps.saveSubtitlePosition(parsedPosition);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-mecab-status', () => {
|
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
||||||
return deps.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);
|
deps.setMecabEnabled(enabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('mpv-command', (_event: IpcMainEvent, command: (string | number)[]) => {
|
ipc.on(IPC_CHANNELS.command.mpvCommand, (_event: unknown, command: unknown) => {
|
||||||
deps.handleMpvCommand(command);
|
const parsedCommand = parseMpvCommand(command);
|
||||||
|
if (!parsedCommand) return;
|
||||||
|
deps.handleMpvCommand(parsedCommand);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-keybindings', () => {
|
ipc.handle(IPC_CHANNELS.request.getKeybindings, () => {
|
||||||
return deps.getKeybindings();
|
return deps.getKeybindings();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-config-shortcuts', () => {
|
ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => {
|
||||||
return deps.getConfiguredShortcuts();
|
return deps.getConfiguredShortcuts();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-secondary-sub-mode', () => {
|
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
|
||||||
return deps.getSecondarySubMode();
|
return deps.getSecondarySubMode();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-current-secondary-sub', () => {
|
ipc.handle(IPC_CHANNELS.request.getCurrentSecondarySub, () => {
|
||||||
return deps.getCurrentSecondarySub();
|
return deps.getCurrentSecondarySub();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('focus-main-window', () => {
|
ipc.handle(IPC_CHANNELS.request.focusMainWindow, () => {
|
||||||
deps.focusMainWindow();
|
deps.focusMainWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('subsync:run-manual', async (_event, request: unknown) => {
|
ipc.handle(IPC_CHANNELS.request.runSubsyncManual, async (_event, request: unknown) => {
|
||||||
return await deps.runSubsyncManual(request);
|
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();
|
return deps.getAnkiConnectStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('runtime-options:get', () => {
|
ipc.handle(IPC_CHANNELS.request.getRuntimeOptions, () => {
|
||||||
return deps.getRuntimeOptions();
|
return deps.getRuntimeOptions();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('runtime-options:set', (_event, id: string, value: unknown) => {
|
ipc.handle(IPC_CHANNELS.request.setRuntimeOption, (_event, id: unknown, value: unknown) => {
|
||||||
return deps.setRuntimeOption(id, value);
|
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) => {
|
ipc.handle(IPC_CHANNELS.request.cycleRuntimeOption, (_event, id: unknown, direction: unknown) => {
|
||||||
return deps.cycleRuntimeOption(id, direction);
|
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);
|
deps.reportOverlayContentBounds(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('anilist:get-status', () => {
|
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
|
||||||
return deps.getAnilistStatus();
|
return deps.getAnilistStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('anilist:clear-token', () => {
|
ipc.handle(IPC_CHANNELS.request.clearAnilistToken, () => {
|
||||||
deps.clearAnilistToken();
|
deps.clearAnilistToken();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('anilist:open-setup', () => {
|
ipc.handle(IPC_CHANNELS.request.openAnilistSetup, () => {
|
||||||
deps.openAnilistSetup();
|
deps.openAnilistSetup();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('anilist:get-queue-status', () => {
|
ipc.handle(IPC_CHANNELS.request.getAnilistQueueStatus, () => {
|
||||||
return deps.getAnilistQueueStatus();
|
return deps.getAnilistQueueStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('anilist:retry-now', async () => {
|
ipc.handle(IPC_CHANNELS.request.retryAnilistNow, async () => {
|
||||||
return await deps.retryAnilistQueueNow();
|
return await deps.retryAnilistQueueNow();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('clipboard:append-video-to-queue', () => {
|
ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => {
|
||||||
return deps.appendClipboardVideoToQueue();
|
return deps.appendClipboardVideoToQueue();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/main.ts
10
src/main.ts
@@ -2622,8 +2622,7 @@ const {
|
|||||||
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
|
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
|
||||||
},
|
},
|
||||||
handleMpvCommandFromIpcRuntime,
|
handleMpvCommandFromIpcRuntime,
|
||||||
runSubsyncManualFromIpc: (request) =>
|
runSubsyncManualFromIpc: (request) => subsyncRuntime.runManualFromIpc(request),
|
||||||
subsyncRuntime.runManualFromIpc(request as SubsyncManualRunRequest),
|
|
||||||
registration: {
|
registration: {
|
||||||
runtimeOptions: {
|
runtimeOptions: {
|
||||||
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
|
||||||
@@ -2641,8 +2640,8 @@ const {
|
|||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOverlayModalClosed: (modal: string) => {
|
onOverlayModalClosed: (modal) => {
|
||||||
handleOverlayModalClosed(modal as OverlayHostedModal);
|
handleOverlayModalClosed(modal);
|
||||||
},
|
},
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
quitApp: () => app.quit(),
|
quitApp: () => app.quit(),
|
||||||
@@ -2656,8 +2655,7 @@ const {
|
|||||||
const resolvedConfig = getResolvedConfig();
|
const resolvedConfig = getResolvedConfig();
|
||||||
return resolveSubtitleStyleForRenderer(resolvedConfig);
|
return resolveSubtitleStyleForRenderer(resolvedConfig);
|
||||||
},
|
},
|
||||||
saveSubtitlePosition: (position: unknown) =>
|
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
|
||||||
saveSubtitlePosition(position as SubtitlePosition),
|
|
||||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||||
getKeybindings: () => appState.keybindings,
|
getKeybindings: () => appState.keybindings,
|
||||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||||
|
|||||||
@@ -26,23 +26,17 @@ export interface SubsyncRuntimeDepsParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createRuntimeOptionsIpcDeps(params: RuntimeOptionsIpcDepsParams): {
|
export function createRuntimeOptionsIpcDeps(params: RuntimeOptionsIpcDepsParams): {
|
||||||
setRuntimeOption: (id: string, value: unknown) => unknown;
|
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||||
cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown;
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
setRuntimeOption: (id, value) =>
|
setRuntimeOption: (id, value) =>
|
||||||
setRuntimeOptionFromIpcRuntime(
|
setRuntimeOptionFromIpcRuntime(params.getRuntimeOptionsManager(), id, value, (text) =>
|
||||||
params.getRuntimeOptionsManager(),
|
params.showMpvOsd(text),
|
||||||
id as RuntimeOptionId,
|
|
||||||
value as RuntimeOptionValue,
|
|
||||||
(text) => params.showMpvOsd(text),
|
|
||||||
),
|
),
|
||||||
cycleRuntimeOption: (id, direction) =>
|
cycleRuntimeOption: (id, direction) =>
|
||||||
cycleRuntimeOptionFromIpcRuntime(
|
cycleRuntimeOptionFromIpcRuntime(params.getRuntimeOptionsManager(), id, direction, (text) =>
|
||||||
params.getRuntimeOptionsManager(),
|
params.showMpvOsd(text),
|
||||||
id as RuntimeOptionId,
|
|
||||||
direction,
|
|
||||||
(text) => params.showMpvOsd(text),
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { composeIpcRuntimeHandlers } from './ipc-runtime-composer';
|
|||||||
|
|
||||||
test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => {
|
test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => {
|
||||||
let registered = false;
|
let registered = false;
|
||||||
|
let receivedSourceTrackId: number | null | undefined;
|
||||||
|
|
||||||
const composed = composeIpcRuntimeHandlers({
|
const composed = composeIpcRuntimeHandlers({
|
||||||
mpvCommandMainDeps: {
|
mpvCommandMainDeps: {
|
||||||
@@ -18,10 +19,13 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
hasRuntimeOptionsManager: () => true,
|
hasRuntimeOptionsManager: () => true,
|
||||||
},
|
},
|
||||||
handleMpvCommandFromIpcRuntime: () => {},
|
handleMpvCommandFromIpcRuntime: () => {},
|
||||||
runSubsyncManualFromIpc: async (request) => ({
|
runSubsyncManualFromIpc: async (request) => {
|
||||||
ok: true,
|
receivedSourceTrackId = request.sourceTrackId;
|
||||||
received: (request as { value: number }).value,
|
return {
|
||||||
}),
|
ok: true,
|
||||||
|
message: 'ok',
|
||||||
|
};
|
||||||
|
},
|
||||||
registration: {
|
registration: {
|
||||||
runtimeOptions: {
|
runtimeOptions: {
|
||||||
getRuntimeOptionsManager: () => null,
|
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.runSubsyncManualFromIpc, 'function');
|
||||||
assert.equal(typeof composed.registerIpcRuntimeHandlers, 'function');
|
assert.equal(typeof composed.registerIpcRuntimeHandlers, 'function');
|
||||||
|
|
||||||
const result = (await composed.runSubsyncManualFromIpc({ value: 7 })) as {
|
const result = await composed.runSubsyncManualFromIpc({
|
||||||
ok: boolean;
|
engine: 'alass',
|
||||||
received: number;
|
sourceTrackId: 7,
|
||||||
};
|
});
|
||||||
assert.deepEqual(result, { ok: true, received: 7 });
|
assert.deepEqual(result, { ok: true, message: 'ok' });
|
||||||
|
assert.equal(receivedSourceTrackId, 7);
|
||||||
|
|
||||||
composed.registerIpcRuntimeHandlers();
|
composed.registerIpcRuntimeHandlers();
|
||||||
assert.equal(registered, true);
|
assert.equal(registered, true);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { RegisterIpcRuntimeServicesParams } from '../../ipc-runtime';
|
import type { RegisterIpcRuntimeServicesParams } from '../../ipc-runtime';
|
||||||
|
import type { SubsyncManualRunRequest, SubsyncResult } from '../../../types';
|
||||||
import {
|
import {
|
||||||
createBuildMpvCommandFromIpcRuntimeMainDepsHandler,
|
createBuildMpvCommandFromIpcRuntimeMainDepsHandler,
|
||||||
createIpcRuntimeHandlers,
|
createIpcRuntimeHandlers,
|
||||||
@@ -11,7 +12,9 @@ type IpcMainDeps = RegisterIpcRuntimeServicesParams['mainDeps'];
|
|||||||
type IpcMainDepsWithoutHandlers = Omit<IpcMainDeps, 'handleMpvCommand' | 'runSubsyncManual'>;
|
type IpcMainDepsWithoutHandlers = Omit<IpcMainDeps, 'handleMpvCommand' | 'runSubsyncManual'>;
|
||||||
type RunSubsyncManual = IpcMainDeps['runSubsyncManual'];
|
type RunSubsyncManual = IpcMainDeps['runSubsyncManual'];
|
||||||
|
|
||||||
type IpcRuntimeDeps = Parameters<typeof createIpcRuntimeHandlers<unknown, unknown>>[0];
|
type IpcRuntimeDeps = Parameters<
|
||||||
|
typeof createIpcRuntimeHandlers<SubsyncManualRunRequest, SubsyncResult>
|
||||||
|
>[0];
|
||||||
|
|
||||||
export type IpcRuntimeComposerOptions = ComposerInputs<{
|
export type IpcRuntimeComposerOptions = ComposerInputs<{
|
||||||
mpvCommandMainDeps: Parameters<typeof createBuildMpvCommandFromIpcRuntimeMainDepsHandler>[0];
|
mpvCommandMainDeps: Parameters<typeof createBuildMpvCommandFromIpcRuntimeMainDepsHandler>[0];
|
||||||
@@ -38,8 +41,8 @@ export function composeIpcRuntimeHandlers(
|
|||||||
options.mpvCommandMainDeps,
|
options.mpvCommandMainDeps,
|
||||||
)();
|
)();
|
||||||
const { handleMpvCommandFromIpc, runSubsyncManualFromIpc } = createIpcRuntimeHandlers<
|
const { handleMpvCommandFromIpc, runSubsyncManualFromIpc } = createIpcRuntimeHandlers<
|
||||||
unknown,
|
SubsyncManualRunRequest,
|
||||||
unknown
|
SubsyncResult
|
||||||
>({
|
>({
|
||||||
handleMpvCommandFromIpcDeps: {
|
handleMpvCommandFromIpcDeps: {
|
||||||
handleMpvCommandFromIpcRuntime: options.handleMpvCommandFromIpcRuntime,
|
handleMpvCommandFromIpcRuntime: options.handleMpvCommandFromIpcRuntime,
|
||||||
@@ -56,7 +59,7 @@ export function composeIpcRuntimeHandlers(
|
|||||||
mainDeps: {
|
mainDeps: {
|
||||||
...options.registration.mainDeps,
|
...options.registration.mainDeps,
|
||||||
handleMpvCommand: (command) => handleMpvCommandFromIpc(command),
|
handleMpvCommand: (command) => handleMpvCommandFromIpc(command),
|
||||||
runSubsyncManual: (request: unknown) => runSubsyncManualFromIpc(request),
|
runSubsyncManual: (request) => runSubsyncManualFromIpc(request),
|
||||||
},
|
},
|
||||||
ankiJimakuDeps: options.registration.ankiJimakuDeps,
|
ankiJimakuDeps: options.registration.ankiJimakuDeps,
|
||||||
});
|
});
|
||||||
|
|||||||
131
src/preload.ts
131
src/preload.ts
@@ -50,6 +50,7 @@ import type {
|
|||||||
ShortcutsConfig,
|
ShortcutsConfig,
|
||||||
ConfigHotReloadPayload,
|
ConfigHotReloadPayload,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
|
||||||
const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer='));
|
const overlayLayerArg = process.argv.find((arg) => arg.startsWith('--overlay-layer='));
|
||||||
const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length);
|
const overlayLayerFromArg = overlayLayerArg?.slice('--overlay-layer='.length);
|
||||||
@@ -61,47 +62,56 @@ const overlayLayer =
|
|||||||
const electronAPI: ElectronAPI = {
|
const electronAPI: ElectronAPI = {
|
||||||
getOverlayLayer: () => overlayLayer,
|
getOverlayLayer: () => overlayLayer,
|
||||||
onSubtitle: (callback: (data: SubtitleData) => void) => {
|
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),
|
callback(data),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
onVisibility: (callback: (visible: boolean) => void) => {
|
onVisibility: (callback: (visible: boolean) => void) => {
|
||||||
ipcRenderer.on('mpv:subVisibility', (_event: IpcRendererEvent, visible: boolean) =>
|
ipcRenderer.on(
|
||||||
callback(visible),
|
IPC_CHANNELS.event.subtitleVisibility,
|
||||||
|
(_event: IpcRendererEvent, visible: boolean) => callback(visible),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => {
|
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
'subtitle-position:set',
|
IPC_CHANNELS.event.subtitlePositionSet,
|
||||||
(_event: IpcRendererEvent, position: SubtitlePosition | null) => {
|
(_event: IpcRendererEvent, position: SubtitlePosition | null) => {
|
||||||
callback(position);
|
callback(position);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getOverlayVisibility: (): Promise<boolean> => ipcRenderer.invoke('get-overlay-visibility'),
|
getOverlayVisibility: (): Promise<boolean> =>
|
||||||
getCurrentSubtitle: (): Promise<SubtitleData> => ipcRenderer.invoke('get-current-subtitle'),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getOverlayVisibility),
|
||||||
getCurrentSubtitleRaw: (): Promise<string> => ipcRenderer.invoke('get-current-subtitle-raw'),
|
getCurrentSubtitle: (): Promise<SubtitleData> =>
|
||||||
getCurrentSubtitleAss: (): Promise<string> => ipcRenderer.invoke('get-current-subtitle-ass'),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitle),
|
||||||
getMpvSubtitleRenderMetrics: () => ipcRenderer.invoke('get-mpv-subtitle-render-metrics'),
|
getCurrentSubtitleRaw: (): Promise<string> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
|
||||||
|
getCurrentSubtitleAss: (): Promise<string> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss),
|
||||||
|
getMpvSubtitleRenderMetrics: () =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getMpvSubtitleRenderMetrics),
|
||||||
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => {
|
onMpvSubtitleRenderMetrics: (callback: (metrics: MpvSubtitleRenderMetrics) => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
'mpv-subtitle-render-metrics:set',
|
IPC_CHANNELS.event.mpvSubtitleRenderMetricsSet,
|
||||||
(_event: IpcRendererEvent, metrics: MpvSubtitleRenderMetrics) => {
|
(_event: IpcRendererEvent, metrics: MpvSubtitleRenderMetrics) => {
|
||||||
callback(metrics);
|
callback(metrics);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onSubtitleAss: (callback: (assText: string) => void) => {
|
onSubtitleAss: (callback: (assText: string) => void) => {
|
||||||
ipcRenderer.on('subtitle-ass:set', (_event: IpcRendererEvent, assText: string) => {
|
ipcRenderer.on(
|
||||||
callback(assText);
|
IPC_CHANNELS.event.subtitleAssSet,
|
||||||
});
|
(_event: IpcRendererEvent, assText: string) => {
|
||||||
|
callback(assText);
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => {
|
onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
'overlay-debug-visualization:set',
|
IPC_CHANNELS.event.overlayDebugVisualizationSet,
|
||||||
(_event: IpcRendererEvent, enabled: boolean) => {
|
(_event: IpcRendererEvent, enabled: boolean) => {
|
||||||
callback(enabled);
|
callback(enabled);
|
||||||
},
|
},
|
||||||
@@ -109,138 +119,147 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
ipcRenderer.send('set-ignore-mouse-events', ignore, options);
|
ipcRenderer.send(IPC_CHANNELS.command.setIgnoreMouseEvents, ignore, options);
|
||||||
},
|
},
|
||||||
|
|
||||||
openYomitanSettings: () => {
|
openYomitanSettings: () => {
|
||||||
ipcRenderer.send('open-yomitan-settings');
|
ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings);
|
||||||
},
|
},
|
||||||
|
|
||||||
getSubtitlePosition: (): Promise<SubtitlePosition | null> =>
|
getSubtitlePosition: (): Promise<SubtitlePosition | null> =>
|
||||||
ipcRenderer.invoke('get-subtitle-position'),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitlePosition),
|
||||||
saveSubtitlePosition: (position: SubtitlePosition) => {
|
saveSubtitlePosition: (position: SubtitlePosition) => {
|
||||||
ipcRenderer.send('save-subtitle-position', position);
|
ipcRenderer.send(IPC_CHANNELS.command.saveSubtitlePosition, position);
|
||||||
},
|
},
|
||||||
|
|
||||||
getMecabStatus: (): Promise<MecabStatus> => ipcRenderer.invoke('get-mecab-status'),
|
getMecabStatus: (): Promise<MecabStatus> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getMecabStatus),
|
||||||
setMecabEnabled: (enabled: boolean) => {
|
setMecabEnabled: (enabled: boolean) => {
|
||||||
ipcRenderer.send('set-mecab-enabled', enabled);
|
ipcRenderer.send(IPC_CHANNELS.command.setMecabEnabled, enabled);
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMpvCommand: (command: (string | number)[]) => {
|
sendMpvCommand: (command: (string | number)[]) => {
|
||||||
ipcRenderer.send('mpv-command', command);
|
ipcRenderer.send(IPC_CHANNELS.command.mpvCommand, command);
|
||||||
},
|
},
|
||||||
|
|
||||||
getKeybindings: (): Promise<Keybinding[]> => ipcRenderer.invoke('get-keybindings'),
|
getKeybindings: (): Promise<Keybinding[]> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
||||||
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
||||||
ipcRenderer.invoke('get-config-shortcuts'),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
||||||
|
|
||||||
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> => ipcRenderer.invoke('jimaku:get-media-info'),
|
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
|
||||||
jimakuSearchEntries: (query: JimakuSearchQuery): Promise<JimakuApiResponse<JimakuEntry[]>> =>
|
jimakuSearchEntries: (query: JimakuSearchQuery): Promise<JimakuApiResponse<JimakuEntry[]>> =>
|
||||||
ipcRenderer.invoke('jimaku:search-entries', query),
|
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuSearchEntries, query),
|
||||||
jimakuListFiles: (query: JimakuFilesQuery): Promise<JimakuApiResponse<JimakuFileEntry[]>> =>
|
jimakuListFiles: (query: JimakuFilesQuery): Promise<JimakuApiResponse<JimakuFileEntry[]>> =>
|
||||||
ipcRenderer.invoke('jimaku:list-files', query),
|
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuListFiles, query),
|
||||||
jimakuDownloadFile: (query: JimakuDownloadQuery): Promise<JimakuDownloadResult> =>
|
jimakuDownloadFile: (query: JimakuDownloadQuery): Promise<JimakuDownloadResult> =>
|
||||||
ipcRenderer.invoke('jimaku:download-file', query),
|
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuDownloadFile, query),
|
||||||
|
|
||||||
quitApp: () => {
|
quitApp: () => {
|
||||||
ipcRenderer.send('quit-app');
|
ipcRenderer.send(IPC_CHANNELS.command.quitApp);
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleDevTools: () => {
|
toggleDevTools: () => {
|
||||||
ipcRenderer.send('toggle-dev-tools');
|
ipcRenderer.send(IPC_CHANNELS.command.toggleDevTools);
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleOverlay: () => {
|
toggleOverlay: () => {
|
||||||
ipcRenderer.send('toggle-overlay');
|
ipcRenderer.send(IPC_CHANNELS.command.toggleOverlay);
|
||||||
},
|
},
|
||||||
|
|
||||||
getAnkiConnectStatus: (): Promise<boolean> => ipcRenderer.invoke('get-anki-connect-status'),
|
getAnkiConnectStatus: (): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getAnkiConnectStatus),
|
||||||
setAnkiConnectEnabled: (enabled: boolean) => {
|
setAnkiConnectEnabled: (enabled: boolean) => {
|
||||||
ipcRenderer.send('set-anki-connect-enabled', enabled);
|
ipcRenderer.send(IPC_CHANNELS.command.setAnkiConnectEnabled, enabled);
|
||||||
},
|
},
|
||||||
clearAnkiConnectHistory: () => {
|
clearAnkiConnectHistory: () => {
|
||||||
ipcRenderer.send('clear-anki-connect-history');
|
ipcRenderer.send(IPC_CHANNELS.command.clearAnkiConnectHistory);
|
||||||
},
|
},
|
||||||
|
|
||||||
onSecondarySub: (callback: (text: string) => void) => {
|
onSecondarySub: (callback: (text: string) => void) => {
|
||||||
ipcRenderer.on('secondary-subtitle:set', (_event: IpcRendererEvent, text: string) =>
|
ipcRenderer.on(
|
||||||
callback(text),
|
IPC_CHANNELS.event.secondarySubtitleSet,
|
||||||
|
(_event: IpcRendererEvent, text: string) => callback(text),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => {
|
onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => {
|
||||||
ipcRenderer.on('secondary-subtitle:mode', (_event: IpcRendererEvent, mode: SecondarySubMode) =>
|
ipcRenderer.on(
|
||||||
callback(mode),
|
IPC_CHANNELS.event.secondarySubtitleMode,
|
||||||
|
(_event: IpcRendererEvent, mode: SecondarySubMode) => callback(mode),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getSecondarySubMode: (): Promise<SecondarySubMode> =>
|
getSecondarySubMode: (): Promise<SecondarySubMode> =>
|
||||||
ipcRenderer.invoke('get-secondary-sub-mode'),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getSecondarySubMode),
|
||||||
getCurrentSecondarySub: (): Promise<string> => ipcRenderer.invoke('get-current-secondary-sub'),
|
getCurrentSecondarySub: (): Promise<string> =>
|
||||||
focusMainWindow: () => ipcRenderer.invoke('focus-main-window') as Promise<void>,
|
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSecondarySub),
|
||||||
|
focusMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.request.focusMainWindow) as Promise<void>,
|
||||||
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
|
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
|
||||||
ipcRenderer.invoke('get-subtitle-style'),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle),
|
||||||
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => {
|
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
'subsync:open-manual',
|
IPC_CHANNELS.event.subsyncOpenManual,
|
||||||
(_event: IpcRendererEvent, payload: SubsyncManualPayload) => {
|
(_event: IpcRendererEvent, payload: SubsyncManualPayload) => {
|
||||||
callback(payload);
|
callback(payload);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
runSubsyncManual: (request: SubsyncManualRunRequest): Promise<SubsyncResult> =>
|
runSubsyncManual: (request: SubsyncManualRunRequest): Promise<SubsyncResult> =>
|
||||||
ipcRenderer.invoke('subsync:run-manual', request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
|
||||||
|
|
||||||
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => {
|
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
'kiku:field-grouping-request',
|
IPC_CHANNELS.event.kikuFieldGroupingRequest,
|
||||||
(_event: IpcRendererEvent, data: KikuFieldGroupingRequestData) => callback(data),
|
(_event: IpcRendererEvent, data: KikuFieldGroupingRequestData) => callback(data),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> =>
|
kikuBuildMergePreview: (request: KikuMergePreviewRequest): Promise<KikuMergePreviewResponse> =>
|
||||||
ipcRenderer.invoke('kiku:build-merge-preview', request),
|
ipcRenderer.invoke(IPC_CHANNELS.request.kikuBuildMergePreview, request),
|
||||||
|
|
||||||
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => {
|
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => {
|
||||||
ipcRenderer.send('kiku:field-grouping-respond', choice);
|
ipcRenderer.send(IPC_CHANNELS.command.kikuFieldGroupingRespond, choice);
|
||||||
},
|
},
|
||||||
|
|
||||||
getRuntimeOptions: (): Promise<RuntimeOptionState[]> => ipcRenderer.invoke('runtime-options:get'),
|
getRuntimeOptions: (): Promise<RuntimeOptionState[]> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getRuntimeOptions),
|
||||||
setRuntimeOptionValue: (
|
setRuntimeOptionValue: (
|
||||||
id: RuntimeOptionId,
|
id: RuntimeOptionId,
|
||||||
value: RuntimeOptionValue,
|
value: RuntimeOptionValue,
|
||||||
): Promise<RuntimeOptionApplyResult> => ipcRenderer.invoke('runtime-options:set', id, value),
|
): Promise<RuntimeOptionApplyResult> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.setRuntimeOption, id, value),
|
||||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1): Promise<RuntimeOptionApplyResult> =>
|
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1): Promise<RuntimeOptionApplyResult> =>
|
||||||
ipcRenderer.invoke('runtime-options:cycle', id, direction),
|
ipcRenderer.invoke(IPC_CHANNELS.request.cycleRuntimeOption, id, direction),
|
||||||
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => {
|
onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
'runtime-options:changed',
|
IPC_CHANNELS.event.runtimeOptionsChanged,
|
||||||
(_event: IpcRendererEvent, options: RuntimeOptionState[]) => {
|
(_event: IpcRendererEvent, options: RuntimeOptionState[]) => {
|
||||||
callback(options);
|
callback(options);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onOpenRuntimeOptions: (callback: () => void) => {
|
onOpenRuntimeOptions: (callback: () => void) => {
|
||||||
ipcRenderer.on('runtime-options:open', () => {
|
ipcRenderer.on(IPC_CHANNELS.event.runtimeOptionsOpen, () => {
|
||||||
callback();
|
callback();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onOpenJimaku: (callback: () => void) => {
|
onOpenJimaku: (callback: () => void) => {
|
||||||
ipcRenderer.on('jimaku:open', () => {
|
ipcRenderer.on(IPC_CHANNELS.event.jimakuOpen, () => {
|
||||||
callback();
|
callback();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||||
ipcRenderer.invoke('clipboard:append-video-to-queue'),
|
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
||||||
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => {
|
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku') => {
|
||||||
ipcRenderer.send('overlay:modal-closed', modal);
|
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
||||||
},
|
},
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||||
ipcRenderer.send('overlay-content-bounds:report', measurement);
|
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement);
|
||||||
},
|
},
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
'config:hot-reload',
|
IPC_CHANNELS.event.configHotReload,
|
||||||
(_event: IpcRendererEvent, payload: ConfigHotReloadPayload) => {
|
(_event: IpcRendererEvent, payload: ConfigHotReloadPayload) => {
|
||||||
callback(payload);
|
callback(payload);
|
||||||
},
|
},
|
||||||
|
|||||||
86
src/shared/ipc/contracts.ts
Normal file
86
src/shared/ipc/contracts.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
157
src/shared/ipc/validators.ts
Normal file
157
src/shared/ipc/validators.ts
Normal file
@@ -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<string, unknown> {
|
||||||
|
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<string | number> | null {
|
||||||
|
if (!Array.isArray(value)) return null;
|
||||||
|
return value.every((entry) => typeof entry === 'string' || typeof entry === 'number')
|
||||||
|
? (value as Array<string | number>)
|
||||||
|
: 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user