refactor(main): eliminate unsafe runtime cast escapes

Tighten main/runtime dependency contracts to remove non-test `as never` and `as unknown as` usage so type drift surfaces during compile/test checks instead of at runtime.
This commit is contained in:
2026-02-22 13:59:08 -08:00
parent 420b985c7a
commit a6d85def34
38 changed files with 679 additions and 444 deletions

View File

@@ -1,10 +1,11 @@
---
id: TASK-105
title: Eliminate unsafe non-test runtime casts in main boundaries
status: To Do
assignee: []
status: Done
assignee:
- opencode-task105-unsafe-casts
created_date: '2026-02-22 07:13'
updated_date: '2026-02-22 07:13'
updated_date: '2026-02-22 21:56'
labels:
- refactor
- type-safety
@@ -36,15 +37,43 @@ Current scan shows repeated casts in dependency builders and runtime adapters, w
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Non-test `as never` occurrences in `src/main.ts` and `src/main/runtime` are reduced to zero or documented narrow exceptions.
- [ ] #2 Runtime dependency builders compile without unsafe production-path cast escapes.
- [ ] #3 Contract regressions are caught by compile/test checks rather than runtime behavior.
- [x] #1 Non-test `as never` occurrences in `src/main.ts` and `src/main/runtime` are reduced to zero or documented narrow exceptions.
- [x] #2 Runtime dependency builders compile without unsafe production-path cast escapes.
- [x] #3 Contract regressions are caught by compile/test checks rather than runtime behavior.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1) Baseline cast inventory in non-test scope (`src/main.ts`, `src/main/runtime/*`) using `rg` and record before-count in notes.
2) Tighten runtime adapter contracts to remove `as never` escapes in `*-main-deps` modules: MPV/Jellyfin defaults, startup config deps, overlay visibility/options deps, field-grouping deps, CLI context deps, subtitle tokenization deps, dictionary runtime deps, tray/app deps, and MPV event bindings.
3) Update `src/main.ts` boundary wiring to match tightened contracts and remove cast escapes in config hot-reload setters, dictionary lookup setters, MPV defaults/OSD wiring, MPV client factory constructor typing, tray creation, and overlay bootstrap callbacks.
4) Re-run cast scan expecting zero non-test unsafe casts (or document narrow exceptions), run required gates (`bun run build`, `bun run test:core:src`), then attach before/after cast report and finalize AC/DoD.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Executed TASK-105 implementation with runtime contract tightening across `src/main.ts` + `src/main/runtime/*` non-test files. Removed all unsafe `as never` / `as unknown as` casts in target scope by replacing unknown passthrough contracts with typed deps (MPV/Jellyfin runtime types, overlay/bootstrap deps, tokenizer/CLI deps, dictionary lookup function types, tray deps typing).
Cast reduction report: baseline scan count = 42 non-test matches (`rg -n "as\\s+never|as\\s+unknown\\s+as" src/main.ts src/main/runtime --glob '!**/*.test.ts'`), after changes = 0 matches with same command.
Verification: targeted runtime cast-refactor tests passed (`bun test ...` across 19 files, 35 pass / 0 fail). Required core lane passed: `bun run test:core:src` => 236 pass / 6 skip / 0 fail.
Blocker: `bun run build` currently fails on pre-existing unrelated typing issues in `src/anki-integration/note-update-workflow.test.ts` (6 TS errors). No remaining build errors from TASK-105 touched files.
Follow-up verification rerun after concurrent compile-fix pass: `bun run build` now succeeds on current HEAD (tsc + renderer build + packaging copy step). DoD build gate unblocked.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Eliminated unsafe non-test runtime cast escapes in `src/main.ts` and `src/main/runtime/*` by tightening runtime dependency-builder contracts (MPV/Jellyfin, overlay/bootstrap, CLI/tokenizer, dictionary/tray boundaries) and removing `as never` / `as unknown as` patterns in production paths. Captured cast reduction evidence (42 -> 0 matches in scoped scan), validated focused runtime suites (35 pass), and revalidated required gates (`bun run test:core:src`, `bun run build`) on current HEAD.
<!-- SECTION:FINAL_SUMMARY:END -->
## Definition of Done
<!-- DOD:BEGIN -->
- [ ] #1 Cast reduction report attached in task notes (before/after counts).
- [ ] #2 `bun run build` and `bun run test:core:src` pass.
- [ ] #3 Any remaining exceptions have explicit rationale in code comments or task notes.
- [x] #1 Cast reduction report attached in task notes (before/after counts).
- [x] #2 `bun run build` and `bun run test:core:src` pass.
- [x] #3 Any remaining exceptions have explicit rationale in code comments or task notes.
<!-- DOD:END -->

View File

@@ -3,7 +3,8 @@
Read first. Keep concise.
| agent_id | alias | mission | status | file | last_update_utc |
| ------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------- | -------------------------------------------------------------------------------------- | ---------------------- |
| ------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------- | -------------------------------------------------------------------------------------- | ---------------------- |
| `codex-field-grouping-autoupdate-race-20260222T193915Z-m8p4` | `codex-field-grouping-autoupdate-race` | `Fix Kiku field-grouping merge race with auto-update so note enrichment completes before duplicate merge` | `handoff` | `docs/subagents/agents/codex-field-grouping-autoupdate-race-20260222T193915Z-m8p4.md` | `2026-02-22T19:41:20Z` |
| `codex-aniskip-intro-skip-20260222T080257Z-51fx` | `codex-aniskip-intro-skip` | `Port intro skip to AniSkip API in mpv plugin with OSD skip button` | `handoff` | `docs/subagents/agents/codex-aniskip-intro-skip-20260222T080257Z-51fx.md` | `2026-02-22T10:04:20Z` |
| `codex-generate-minecard-image-20260220T112900Z-vsxr` | `codex-generate-minecard-image` | `Generate media fallbacks (GIF) from assets/minecard.webm and wire README/docs fallback markup` | `done` | `docs/subagents/agents/codex-generate-minecard-image-20260220T112900Z-vsxr.md` | `2026-02-20T11:35:30Z` |
| `codex-main` | `planner-exec` | `Fix frequency/N+1 regression in plugin --start flow` | `in_progress` | `docs/subagents/agents/codex-main.md` | `2026-02-19T19:36:46Z` |
@@ -71,5 +72,12 @@ Read first. Keep concise.
| `codex-jellyfin-ts-fix-20260222T071530Z-5e50` | `codex-jellyfin-ts-fix` | `Fix Jellyfin token/session type drift causing TS compile failures in config+main.` | `done` | `docs/subagents/agents/codex-jellyfin-ts-fix-20260222T071530Z-5e50.md` | `2026-02-22T07:23:47Z` |
| `codex-overlay-toggle-regression-20260222T073450Z-q7m4` | `codex-overlay-toggle-regression` | `Fix post-rebase overlay toggle regression causing transparent non-interactable windows and broken keybinds (TASK-107).` | `testing` | `docs/subagents/agents/codex-overlay-toggle-regression-20260222T073450Z-q7m4.md` | `2026-02-22T07:45:58Z` |
| `codex-docs-review-20260222T094009Z-g8p2` | `codex-docs-review` | `Review README/docs for drift vs current code/scripts; patch stale or missing documentation.` | `done` | `docs/subagents/agents/codex-docs-review-20260222T094009Z-g8p2.md` | `2026-02-22T09:43:52Z` |
| `codex-discord-presence-task-20260222T194048Z-d7k2` | `codex-discord-presence-task` | `Add backlog task for Discord Rich Presence integration with polished activity card` | `done` | `docs/subagents/agents/codex-discord-presence-task-20260222T194048Z-d7k2.md` | `2026-02-22T19:41:00Z` |
| `codex-task108-aniskip-20260222T194600Z-qgdt` | `codex-task108-aniskip` | `Execute TASK-108 end-to-end with plan-first workflow and no commit` | `done` | `docs/subagents/agents/codex-task108-aniskip-20260222T194600Z-qgdt.md` | `2026-02-22T19:49:30Z` |
| `codex-gh-fix-ci-20260222T191948Z-b7n4` | `codex-gh-fix-ci` | `Triage failing GitHub Actions checks on active PR; summarize root cause; propose fix plan before edits` | `handoff` | `docs/subagents/agents/codex-gh-fix-ci-20260222T191948Z-b7n4.md` | `2026-02-22T19:24:12Z` |
| `opencode-task105-unsafe-casts-20260222T194704Z-zfcm` | `opencode-task105-unsafe-casts` | `Execute TASK-105 eliminate unsafe non-test runtime casts in main boundaries end-to-end without commit` | `done` | `docs/subagents/agents/opencode-task105-unsafe-casts-20260222T194704Z-zfcm.md` | `2026-02-22T21:56:30Z` |
| `codex-task104-launcher-config-20260222T194708Z-z9x1` | `codex-task104-launcher-config` | `Execute TASK-104 end-to-end with plan-first workflow and no commit` | `done` | `docs/subagents/agents/codex-task104-launcher-config-20260222T194708Z-z9x1.md` | `2026-02-22T19:56:26Z` |
| `opencode-task106-immersion-modules-20260222T195109Z-r3m7` | `opencode-task106-immersion-modules` | `Execute TASK-106 decomposition of immersion tracker service into storage session and metadata modules end-to-end without commit` | `done` | `docs/subagents/agents/opencode-task106-immersion-modules-20260222T195109Z-r3m7.md` | `2026-02-22T21:58:45Z` |
| `codex-task105-sliceb-20260222T195423Z-w8n3` | `codex-task105-sliceb` | `Implement TASK-105 slice B runtime cast removal in targeted main/runtime modules without commit` | `done` | `docs/subagents/agents/codex-task105-sliceb-20260222T195423Z-w8n3.md` | `2026-02-22T20:02:06Z` |
| `codex-ts-build-errors-20260222T215411Z-h3k7` | `codex-ts-build-errors` | `Fix current TypeScript build failures in anki/runtime tests and deps typing contracts; keep behavior unchanged.` | `done` | `docs/subagents/agents/codex-ts-build-errors-20260222T215411Z-h3k7.md` | `2026-02-22T21:55:54Z` |

View File

@@ -0,0 +1,78 @@
# Agent Session: opencode-task105-unsafe-casts-20260222T194704Z-zfcm
- alias: `opencode-task105-unsafe-casts`
- mission: `Execute TASK-105 eliminate unsafe non-test runtime casts in main boundaries end-to-end without commit.`
- status: `done`
- started_utc: `2026-02-22T19:47:35Z`
- last_update_utc: `2026-02-22T21:56:30Z`
## Intent
- Load TASK-105 context from Backlog MCP and collect exact unsafe cast hotspots.
- Write implementation plan via writing-plans skill with test-first steps and verification gates.
- Execute plan with executing-plans skill, parallelizing independent slices where safe.
## Planned Files
- `src/main.ts`
- `src/main/runtime/**/*.ts`
- `src/main/**/*.test.ts`
- `docs/plans/*.md`
## Files Touched
- `docs/subagents/INDEX.md`
- `docs/subagents/agents/opencode-task105-unsafe-casts-20260222T194704Z-zfcm.md`
- `docs/subagents/collaboration.md`
- `docs/plans/2026-02-22-task-105-unsafe-runtime-casts.md`
- `src/main.ts`
- `src/main/runtime/app-runtime-main-deps.ts`
- `src/main/runtime/cli-command-context-main-deps.ts`
- `src/main/runtime/dictionary-runtime-main-deps.ts`
- `src/main/runtime/field-grouping-overlay-main-deps.ts`
- `src/main/runtime/jellyfin-client-info.ts`
- `src/main/runtime/jellyfin-playback-launch.ts`
- `src/main/runtime/mpv-client-runtime-service.ts`
- `src/main/runtime/mpv-jellyfin-defaults.ts`
- `src/main/runtime/mpv-main-event-bindings.ts`
- `src/main/runtime/mpv-osd-log-main-deps.ts`
- `src/main/runtime/overlay-runtime-bootstrap-handlers.ts`
- `src/main/runtime/overlay-runtime-options-main-deps.ts`
- `src/main/runtime/overlay-runtime-options.ts`
- `src/main/runtime/overlay-visibility-runtime-main-deps.ts`
- `src/main/runtime/startup-config-main-deps.ts`
- `src/main/runtime/subtitle-tokenization-main-deps.ts`
- `src/main/runtime/tray-runtime-handlers.ts`
- `src/main/runtime/cli-command-context-factory.test.ts`
- `src/main/runtime/composers/app-ready-composer.test.ts`
- `src/main/runtime/composers/mpv-runtime-composer.test.ts`
- `src/main/runtime/overlay-runtime-bootstrap-handlers.test.ts`
- `src/main/runtime/overlay-runtime-options-main-deps.test.ts`
- `src/main/runtime/cli-command-context-main-deps.test.ts`
- `src/main/runtime/subtitle-tokenization-main-deps.test.ts`
- `src/main/runtime/jellyfin-playback-launch-main-deps.test.ts`
- `src/main/runtime/jellyfin-playback-launch.test.ts`
- `src/main/runtime/mpv-jellyfin-defaults-main-deps.test.ts`
- `src/main/runtime/mpv-jellyfin-defaults.test.ts`
- `src/main/runtime/field-grouping-overlay-main-deps.test.ts`
- `src/main/runtime/mpv-osd-log-main-deps.test.ts`
- `src/main/runtime/overlay-visibility-runtime-main-deps.test.ts`
- `src/main/runtime/startup-config-main-deps.test.ts`
- `backlog/tasks/task-105 - Eliminate-unsafe-non-test-runtime-casts-in-main-boundaries.md`
## Assumptions
- TASK-105 scope excludes test-only casts and non-main-runtime paths.
- Existing compile/test lanes are authoritative for regression detection.
## Phase Log
- `2026-02-22T19:47:35Z` Session started; loaded backlog workflow overview, subagent coordination docs, and TASK-105 details.
- `2026-02-22T19:52:30Z` Wrote plan at `docs/plans/2026-02-22-task-105-unsafe-runtime-casts.md`, set TASK-105 In Progress, and executed runtime cast-elimination with parallel subagents.
- `2026-02-22T21:10:04Z` Completed cast elimination in non-test target scope; follow-up type harmonization done for tray/bootstrap/test contracts.
- `2026-02-22T21:55:38Z` Verification complete: cast scan after=0, targeted runtime tests green, `test:core:src` green; temporarily blocked on unrelated `note-update-workflow.test.ts` TS errors during `bun run build`.
- `2026-02-22T21:56:30Z` Re-ran `bun run build` after concurrent compile-fix; build passes. TASK-105 finalized Done in Backlog with AC/DoD evidence.
## Next Step
- TASK-105 complete. Optional next step: stage/commit focused TASK-105 diff once user requests commit.

View File

@@ -103,6 +103,7 @@ Shared notes. Append-only.
- [2026-02-22T07:45:58Z] [codex-overlay-toggle-regression-20260222T073450Z-q7m4|codex-overlay-toggle-regression] added explicit overlay BrowserWindow sandbox guard (`webPreferences.sandbox=false`) to avoid preload API break on newer Electron defaults; added regression test `src/core/services/overlay-window-config.test.ts`; verified focused tests + build green.
## 2026-02-22
- [2026-02-22T08:04:10Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] starting feature: port intro skip to AniSkip API in `plugin/subminer.lua` with chapter markers + in-range OSD skip button; scoped to mpv plugin/docs only.
- [2026-02-22T08:05:38Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] implemented AniSkip OP lookup + chapter markers + in-range OSD skip prompt/key in `plugin/subminer.lua`; updated plugin conf/docs; syntax check `luac -p plugin/subminer.lua` passed.
- [2026-02-22T08:13:40Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] follow-up applied: launcher now runs `guessit` for file playback and passes AniSkip title/season/episode script-opts (fallback title from filename), and intro hint now displays for first 3s from intro start (`You can skip by pressing y-k`).
@@ -116,3 +117,20 @@ Shared notes. Append-only.
- [2026-02-22T10:04:20Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] launcher metadata fix: prefer guessit `series` for AniSkip title and fallback to show-directory extraction (`.../<Show>/Season-*`) instead of episode filename title.
- [2026-02-22T10:10:30Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] plugin hardening: path-derived show title now prioritized over script-opt title for AniSkip lookups, reducing dependence on launcher metadata correctness.
- [2026-02-22T10:14:40Z] [codex-aniskip-intro-skip-20260222T080257Z-51fx|codex-aniskip-intro-skip] simplified MAL resolution policy to first-result selection (no score/reject) per user preference.
- [2026-02-22T19:41:00Z] [codex-discord-presence-task-20260222T194048Z-d7k2|codex-discord-presence-task] created `TASK-109` for optional Discord Rich Presence integration with polished activity card UI; no code/runtime changes in this pass.
- [2026-02-22T19:46:00Z] [codex-task108-aniskip-20260222T194600Z-qgdt|codex-task108-aniskip] starting TASK-108 closure pass via Backlog MCP + writing-plans/executing-plans; validating plugin/docs behavior and finalizing AC/DoD evidence.
- [2026-02-22T19:49:30Z] [codex-task108-aniskip-20260222T194600Z-qgdt|codex-task108-aniskip] completed TASK-108 closure: wrote plan artifact, revalidated plugin/docs AC coverage, ran validation suite (`luac` + launcher tests pass), finalized backlog task as Done; `tsc --noEmit` still fails on unrelated pre-existing `src/anki-integration/note-update-workflow.test.ts` errors.
- [2026-02-22T19:47:35Z] [opencode-task105-unsafe-casts-20260222T194704Z-zfcm|opencode-task105-unsafe-casts] starting TASK-105 via Backlog MCP + writing-plans/executing-plans; scope runtime cast elimination in `src/main.ts` + `src/main/runtime/*`, no commit.
- [2026-02-22T19:47:08Z] [codex-task104-launcher-config-20260222T194708Z-z9x1|codex-task104-launcher-config] starting TASK-104 via Backlog MCP + writing-plans/executing-plans; scope launcher config module extraction + focused tests + launcher lanes validation.
- [2026-02-22T19:56:26Z] [codex-task104-launcher-config-20260222T194708Z-z9x1|codex-task104-launcher-config] completed TASK-104: split `launcher/config.ts` into domain modules + CLI builder/normalizer, added focused parser tests, aligned Jellyfin config reader contract (no token/userId fields), and verified `test:launcher` + `test:fast`; backlog task marked Done.
- [2026-02-22T19:51:09Z] [opencode-task106-immersion-modules-20260222T195109Z-r3m7|opencode-task106-immersion-modules] starting TASK-106 via Backlog MCP + writing-plans/executing-plans; scope immersion tracker storage/session/metadata decomposition + focused tests + architecture docs update, no commit.
- [2026-02-22T19:54:23Z] [codex-task105-sliceb-20260222T195423Z-w8n3|codex-task105-sliceb] overlap note: executing user-requested TASK-105 slice B in targeted runtime cast-removal files (`src/main/runtime/*-main-deps.ts`, jellyfin/mpv runtime helpers) while preserving existing TASK-105 planning artifacts.
- [2026-02-22T19:59:42Z] [codex-task105-sliceb-20260222T195423Z-w8n3|codex-task105-sliceb] completed requested slice B: removed unsafe casts in targeted runtime modules, tightened contracts with shared runtime types (`CliCommandContextFactoryDeps`, `TokenizerDepsRuntimeOptions`, overlay options deps), and verified via focused runtime test suite (16 pass, 0 fail).
- [2026-02-22T20:02:06Z] [codex-task105-sliceb-20260222T195423Z-w8n3|codex-task105-sliceb] follow-up typing fallout pass: updated focused runtime tests for stricter contract shapes (texthooker/anilist/mpv/jellyfin/tokenizer stubs) and revalidated same 8-file suite green.
- [2026-02-22T20:01:45Z] [opencode-task106-immersion-modules-20260222T195109Z-r3m7|opencode-task106-immersion-modules] completed TASK-106 implementation scope: extracted `storage.ts`, `session.ts`, `metadata.ts`; reduced `immersion-tracker-service.ts` to 654 LOC facade; added focused tests (`storage-session.test.ts`, `metadata.test.ts`); tracker/core source tests green. Task finalization blocked on AC#4 build gate due unrelated pre-existing TS errors in `src/anki-integration/*` and `src/main/runtime/*`.
- [2026-02-22T21:58:45Z] [opencode-task106-immersion-modules-20260222T195109Z-r3m7|opencode-task106-immersion-modules] blocker cleared; reran `bun run build` + tracker tests + `test:core:src` green; finalized TASK-106 to Done in Backlog with final summary.
- [2026-02-22T21:54:29Z] [codex-ts-build-errors-20260222T215411Z-h3k7|codex-ts-build-errors] overlap note: touching `src/main/runtime/*` and `src/anki-integration/note-update-workflow.test.ts` for strict-typing fallout fixes after TASK-105 slice-B contract tightening; scope limited to compile-error remediation + test stub alignment.
- [2026-02-22T21:55:54Z] [codex-ts-build-errors-20260222T215411Z-h3k7|codex-ts-build-errors] completed compile-fix pass: widened `note-update-workflow.test.ts` harness deps to `NoteUpdateWorkflowDeps`, aligned stub callback signatures, and verified `bun run tsc --noEmit` + `make build` green.
- [2026-02-22T21:56:30Z] [opencode-task105-unsafe-casts-20260222T194704Z-zfcm|opencode-task105-unsafe-casts] finalized TASK-105: re-ran `bun run build` after compile-fix pass (green), confirmed cast scan 42->0 in scope, and moved backlog task to Done with AC/DoD + final summary.

View File

@@ -514,7 +514,7 @@ let yomitanLoadInFlight: Promise<Extension | null> | null = null;
const buildApplyJellyfinMpvDefaultsMainDepsHandler =
createBuildApplyJellyfinMpvDefaultsMainDepsHandler({
sendMpvCommandRuntime: (client, command) => sendMpvCommandRuntime(client as never, command),
sendMpvCommandRuntime: (client, command) => sendMpvCommandRuntime(client, command),
jellyfinLangPref: JELLYFIN_LANG_PREF,
});
const applyJellyfinMpvDefaultsMainDeps = buildApplyJellyfinMpvDefaultsMainDepsHandler();
@@ -522,7 +522,9 @@ const applyJellyfinMpvDefaultsHandler = createApplyJellyfinMpvDefaultsHandler(
applyJellyfinMpvDefaultsMainDeps,
);
function applyJellyfinMpvDefaults(client: MpvIpcClient): void {
function applyJellyfinMpvDefaults(
client: Parameters<typeof applyJellyfinMpvDefaultsHandler>[0],
): void {
applyJellyfinMpvDefaultsHandler(client);
}
@@ -746,8 +748,7 @@ const buildSubtitleProcessingControllerMainDepsHandler =
},
now: () => Date.now(),
});
const subtitleProcessingControllerMainDeps =
buildSubtitleProcessingControllerMainDepsHandler();
const subtitleProcessingControllerMainDeps = buildSubtitleProcessingControllerMainDepsHandler();
const subtitleProcessingController = createSubtitleProcessingController(
subtitleProcessingControllerMainDeps,
);
@@ -811,20 +812,20 @@ const watchConfigPathHandler = createWatchConfigPathHandler(buildWatchConfigPath
const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadAppliedMainDepsHandler(
{
setKeybindings: (keybindings) => {
appState.keybindings = keybindings as never;
appState.keybindings = keybindings;
},
refreshGlobalAndOverlayShortcuts: () => {
refreshGlobalAndOverlayShortcuts();
},
setSecondarySubMode: (mode) => {
appState.secondarySubMode = mode as never;
appState.secondarySubMode = mode;
},
broadcastToOverlayWindows: (channel, payload) => {
broadcastToOverlayWindows(channel, payload);
},
applyAnkiRuntimeConfigPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch as never);
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
},
@@ -912,7 +913,7 @@ const jlptDictionaryRuntime = createJlptDictionaryRuntimeService(
getDictionaryRoots: () => buildDictionaryRootsHandler(),
getJlptDictionarySearchPaths,
setJlptLevelLookup: (lookup) => {
appState.jlptLevelLookup = lookup as never;
appState.jlptLevelLookup = lookup;
},
logInfo: (message) => logger.info(message),
})(),
@@ -926,7 +927,7 @@ const frequencyDictionaryRuntime = createFrequencyDictionaryRuntimeService(
getFrequencyDictionarySearchPaths,
getSourcePath: () => getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath,
setFrequencyRankLookup: (lookup) => {
appState.frequencyRankLookup = lookup as never;
appState.frequencyRankLookup = lookup;
},
logInfo: (message) => logger.info(message),
})(),
@@ -968,7 +969,7 @@ function setFieldGroupingResolver(
}
const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHostedModal>(
createBuildFieldGroupingOverlayMainDepsHandler<OverlayHostedModal, KikuFieldGroupingChoice>({
createBuildFieldGroupingOverlayMainDepsHandler<OverlayHostedModal>({
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => overlayManager.getInvisibleOverlayVisible(),
@@ -1257,8 +1258,7 @@ const buildPlayJellyfinItemInMpvMainDepsHandler = createBuildPlayJellyfinItemInM
subtitleStreamIndex: params.subtitleStreamIndex ?? undefined,
},
),
applyJellyfinMpvDefaults: (mpvClient) =>
applyJellyfinMpvDefaults(mpvClient as unknown as MpvIpcClient),
applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient),
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
armQuitOnDisconnect: () => {
jellyfinPlayQuitOnDisconnectArmed = false;
@@ -2169,10 +2169,7 @@ const {
},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: MpvIpcClient as unknown as new (
socketPath: string,
options: MpvClientRuntimeServiceOptions,
) => MpvIpcClient,
createClient: MpvIpcClient,
getSocketPath: () => appState.mpvSocketPath,
getResolvedConfig: () => getResolvedConfig(),
isAutoStartOverlayEnabled: () => appState.autoStartOverlay,
@@ -2434,7 +2431,7 @@ const { appendToMpvLog, flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers(
buildShowMpvOsdMainDeps: (appendToMpvLogHandler) => ({
appendToMpvLog: (message) => appendToMpvLogHandler(message),
showMpvOsdRuntime: (mpvClient, text, fallbackLog) =>
showMpvOsdRuntime(mpvClient as never, text, fallbackLog),
showMpvOsdRuntime(mpvClient, text, fallbackLog),
getMpvClient: () => appState.mpvClient,
logInfo: (line) => logger.info(line),
}),
@@ -2845,7 +2842,7 @@ const {
},
createImageFromPath: (iconPath) => nativeImage.createFromPath(iconPath),
createEmptyImage: () => nativeImage.createEmpty(),
createTray: (icon) => new Tray(icon as never),
createTray: (icon) => new Tray(icon as ConstructorParameters<typeof Tray>[0]),
trayTooltip: TRAY_TOOLTIP,
platform: process.platform,
logWarn: (message) => logger.warn(message),
@@ -2910,12 +2907,12 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
getOverlayWindows: () => getOverlayWindows(),
getResolvedConfig: () => getResolvedConfig(),
showDesktopNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback() as never,
createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
},
initializeOverlayRuntimeBootstrapDeps: {
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
initializeOverlayRuntimeCore: (options) => initializeOverlayRuntimeCore(options as never),
initializeOverlayRuntimeCore,
setInvisibleOverlayVisible: (visible) => {
overlayManager.setInvisibleOverlayVisible(visible);
},

View File

@@ -1,11 +1,11 @@
export function createBuildEnsureTrayMainDepsHandler(deps: {
getTray: () => unknown | null;
setTray: (tray: unknown | null) => void;
buildTrayMenu: () => unknown;
export function createBuildEnsureTrayMainDepsHandler<TTray, TTrayMenu, TTrayIcon>(deps: {
getTray: () => TTray | null;
setTray: (tray: TTray | null) => void;
buildTrayMenu: () => TTrayMenu;
resolveTrayIconPath: () => string | null;
createImageFromPath: (iconPath: string) => unknown;
createEmptyImage: () => unknown;
createTray: (icon: unknown) => unknown;
createImageFromPath: (iconPath: string) => TTrayIcon;
createEmptyImage: () => TTrayIcon;
createTray: (icon: TTrayIcon) => TTray;
trayTooltip: string;
platform: string;
logWarn: (message: string) => void;
@@ -14,13 +14,13 @@ export function createBuildEnsureTrayMainDepsHandler(deps: {
setVisibleOverlayVisible: (visible: boolean) => void;
}) {
return () => ({
getTray: () => deps.getTray() as never,
setTray: (tray: unknown | null) => deps.setTray(tray),
buildTrayMenu: () => deps.buildTrayMenu() as never,
getTray: () => deps.getTray(),
setTray: (tray: TTray | null) => deps.setTray(tray),
buildTrayMenu: () => deps.buildTrayMenu(),
resolveTrayIconPath: () => deps.resolveTrayIconPath(),
createImageFromPath: (iconPath: string) => deps.createImageFromPath(iconPath) as never,
createEmptyImage: () => deps.createEmptyImage() as never,
createTray: (icon: unknown) => deps.createTray(icon) as never,
createImageFromPath: (iconPath: string) => deps.createImageFromPath(iconPath),
createEmptyImage: () => deps.createEmptyImage(),
createTray: (icon: TTrayIcon) => deps.createTray(icon),
trayTooltip: deps.trayTooltip,
platform: deps.platform,
logWarn: (message: string) => deps.logWarn(message),
@@ -33,28 +33,28 @@ export function createBuildEnsureTrayMainDepsHandler(deps: {
});
}
export function createBuildDestroyTrayMainDepsHandler(deps: {
getTray: () => unknown | null;
setTray: (tray: unknown | null) => void;
export function createBuildDestroyTrayMainDepsHandler<TTray>(deps: {
getTray: () => TTray | null;
setTray: (tray: TTray | null) => void;
}) {
return () => ({
getTray: () => deps.getTray() as never,
setTray: (tray: unknown | null) => deps.setTray(tray),
getTray: () => deps.getTray(),
setTray: (tray: TTray | null) => deps.setTray(tray),
});
}
export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler(deps: {
export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOptions>(deps: {
isOverlayRuntimeInitialized: () => boolean;
initializeOverlayRuntimeCore: (options: unknown) => { invisibleOverlayVisible: boolean };
buildOptions: () => unknown;
initializeOverlayRuntimeCore: (options: TOptions) => { invisibleOverlayVisible: boolean };
buildOptions: () => TOptions;
setInvisibleOverlayVisible: (visible: boolean) => void;
setOverlayRuntimeInitialized: (initialized: boolean) => void;
startBackgroundWarmups: () => void;
}) {
return () => ({
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
initializeOverlayRuntimeCore: (options: unknown) => deps.initializeOverlayRuntimeCore(options),
buildOptions: () => deps.buildOptions() as never,
initializeOverlayRuntimeCore: (options: TOptions) => deps.initializeOverlayRuntimeCore(options),
buildOptions: () => deps.buildOptions(),
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
setOverlayRuntimeInitialized: (initialized: boolean) =>
deps.setOverlayRuntimeInitialized(initialized),
@@ -62,27 +62,27 @@ export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler(deps
});
}
export function createBuildOpenYomitanSettingsMainDepsHandler(deps: {
ensureYomitanExtensionLoaded: () => Promise<unknown | null>;
export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWindow>(deps: {
ensureYomitanExtensionLoaded: () => Promise<TYomitanExt | null>;
openYomitanSettingsWindow: (params: {
yomitanExt: unknown;
getExistingWindow: () => unknown | null;
setWindow: (window: unknown | null) => void;
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
}) => void;
getExistingWindow: () => unknown | null;
setWindow: (window: unknown | null) => void;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
logWarn: (message: string) => void;
logError: (message: string, error: unknown) => void;
}) {
return () => ({
ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(),
openYomitanSettingsWindow: (params: {
yomitanExt: unknown;
getExistingWindow: () => unknown | null;
setWindow: (window: unknown | null) => void;
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
setWindow: (window: TWindow | null) => void;
}) => deps.openYomitanSettingsWindow(params),
getExistingWindow: () => deps.getExistingWindow(),
setWindow: (window: unknown | null) => deps.setWindow(window),
setWindow: (window: TWindow | null) => deps.setWindow(window),
logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, error: unknown) => deps.logError(message, error),
});

View File

@@ -6,14 +6,14 @@ test('cli command context factory composes main deps and context handlers', () =
const calls: string[] = [];
const appState = {
mpvSocketPath: '/tmp/mpv.sock',
mpvClient: null as unknown,
mpvClient: null,
texthookerPort: 5174,
overlayRuntimeInitialized: false,
};
const createContext = createCliCommandContextFactory({
appState,
texthookerService: { start: () => null },
texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }),
openExternal: async () => {},
logBrowserOpenError: () => {},
@@ -32,11 +32,28 @@ test('cli command context factory composes main deps and context handlers', () =
triggerFieldGrouping: async () => {},
triggerSubsyncFromConfig: async () => {},
markLastCardAsAudioCard: async () => {},
getAnilistStatus: () => ({ status: 'ok' }),
getAnilistStatus: () => ({
tokenStatus: 'resolved',
tokenSource: 'literal',
tokenMessage: null,
tokenResolvedAt: null,
tokenErrorAt: null,
queuePending: 0,
queueReady: 0,
queueDeadLetter: 0,
queueLastAttemptAt: null,
queueLastError: null,
}),
clearAnilistToken: () => {},
openAnilistSetupWindow: () => {},
openJellyfinSetupWindow: () => {},
getAnilistQueueStatus: () => ({ queued: 0 }),
getAnilistQueueStatus: () => ({
pending: 0,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
}),
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
runJellyfinCommand: async () => {},
openYomitanSettings: () => {},

View File

@@ -6,14 +6,14 @@ test('cli command context main deps builder maps state and callbacks', async ()
const calls: string[] = [];
const appState = {
mpvSocketPath: '/tmp/mpv.sock',
mpvClient: null as unknown,
mpvClient: null,
texthookerPort: 5174,
overlayRuntimeInitialized: false,
};
const build = createBuildCliCommandContextMainDepsHandler({
appState,
texthookerService: { start: () => null },
texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }),
openExternal: async (url) => {
calls.push(`open:${url}`);
@@ -49,11 +49,28 @@ test('cli command context main deps builder maps state and callbacks', async ()
calls.push('mark-audio');
},
getAnilistStatus: () => ({ status: 'ok' }),
getAnilistStatus: () => ({
tokenStatus: 'resolved',
tokenSource: 'literal',
tokenMessage: null,
tokenResolvedAt: null,
tokenErrorAt: null,
queuePending: 0,
queueReady: 0,
queueDeadLetter: 0,
queueLastAttemptAt: null,
queueLastError: null,
}),
clearAnilistToken: () => calls.push('clear-token'),
openAnilistSetupWindow: () => calls.push('open-anilist-setup'),
openJellyfinSetupWindow: () => calls.push('open-jellyfin-setup'),
getAnilistQueueStatus: () => ({ queued: 1 }),
getAnilistQueueStatus: () => ({
pending: 1,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
}),
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
runJellyfinCommand: async () => {
calls.push('run-jellyfin');

View File

@@ -1,13 +1,16 @@
import type { CliArgs } from '../../cli/args';
import type { CliCommandContextFactoryDeps } from './cli-command-context';
export function createBuildCliCommandContextMainDepsHandler(deps: {
appState: {
type CliCommandContextMainState = {
mpvSocketPath: string;
mpvClient: unknown | null;
mpvClient: ReturnType<CliCommandContextFactoryDeps['getMpvClient']>;
texthookerPort: number;
overlayRuntimeInitialized: boolean;
};
texthookerService: unknown;
export function createBuildCliCommandContextMainDepsHandler(deps: {
appState: CliCommandContextMainState;
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getResolvedConfig: () => { texthooker?: { openBrowser?: boolean } };
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
@@ -29,12 +32,12 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<void>;
getAnilistStatus: () => unknown;
getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus'];
clearAnilistToken: () => void;
openAnilistSetupWindow: () => void;
openJellyfinSetupWindow: () => void;
getAnilistQueueStatus: () => unknown;
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
getAnilistQueueStatus: CliCommandContextFactoryDeps['getAnilistQueueStatus'];
processNextAnilistRetryUpdate: CliCommandContextFactoryDeps['retryAnilistQueueNow'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
openYomitanSettings: () => void;
@@ -49,14 +52,14 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
}) {
return () => ({
return (): CliCommandContextFactoryDeps => ({
getSocketPath: () => deps.appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
deps.appState.mpvSocketPath = socketPath;
},
getMpvClient: () => deps.appState.mpvClient as never,
getMpvClient: () => deps.appState.mpvClient,
showOsd: (text: string) => deps.showMpvOsd(text),
texthookerService: deps.texthookerService as never,
texthookerService: deps.texthookerService,
getTexthookerPort: () => deps.appState.texthookerPort,
setTexthookerPort: (port: number) => {
deps.appState.texthookerPort = port;
@@ -80,11 +83,11 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
triggerFieldGrouping: () => deps.triggerFieldGrouping(),
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(),
getAnilistStatus: () => deps.getAnilistStatus() as never,
getAnilistStatus: () => deps.getAnilistStatus(),
clearAnilistToken: () => deps.clearAnilistToken(),
openAnilistSetup: () => deps.openAnilistSetupWindow(),
openJellyfinSetup: () => deps.openJellyfinSetupWindow(),
getAnilistQueueStatus: () => deps.getAnilistQueueStatus() as never,
getAnilistQueueStatus: () => deps.getAnilistQueueStatus(),
retryAnilistQueueNow: () => deps.processNextAnilistRetryUpdate(),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
openYomitanSettings: () => deps.openYomitanSettings(),

View File

@@ -5,7 +5,7 @@ import { composeAppReadyRuntime } from './app-ready-composer';
test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => {
const composed = composeAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => ({ config: {} as never, warnings: [] }),
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: () => {},
logWarning: () => {},
showDesktopNotification: () => {},

View File

@@ -85,7 +85,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: FakeMpvClient,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({}) as never,
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
shouldBindVisibleOverlayToMpvSubVisibility: () => true,
@@ -118,7 +118,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
setYomitanParserInitPromise: () => {},
isKnownWord: (text) => text === 'known',
recordLookup: () => {},
getKnownWordMatchMode: () => 'exact',
getKnownWordMatchMode: () => 'headword',
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true,

View File

@@ -3,6 +3,7 @@ import { createBuildBindMpvMainEventHandlersMainDepsHandler } from '../mpv-main-
import { createBuildMpvClientRuntimeServiceFactoryDepsHandler } from '../mpv-client-runtime-service-main-deps';
import { createMpvClientRuntimeServiceFactory } from '../mpv-client-runtime-service';
import type { MpvClientRuntimeServiceOptions } from '../mpv-client-runtime-service';
import type { Config } from '../../../types';
import { createBuildUpdateMpvSubtitleRenderMetricsMainDepsHandler } from '../mpv-subtitle-render-metrics-main-deps';
import { createUpdateMpvSubtitleRenderMetricsHandler } from '../mpv-subtitle-render-metrics';
import {
@@ -30,7 +31,7 @@ type MpvClientRuntimeServiceFactoryMainDeps<TMpvClient extends RuntimeMpvClient>
Parameters<
typeof createBuildMpvClientRuntimeServiceFactoryDepsHandler<
TMpvClient,
unknown,
Config,
MpvClientRuntimeServiceOptions
>
>[0],
@@ -107,7 +108,7 @@ export function composeMpvRuntimeHandlers<
const buildMpvClientRuntimeServiceFactoryMainDepsHandler =
createBuildMpvClientRuntimeServiceFactoryDepsHandler<
TMpvClient,
unknown,
Config,
MpvClientRuntimeServiceOptions
>({
...options.mpvClientRuntimeServiceFactoryMainDeps,

View File

@@ -1,3 +1,7 @@
import type { FrequencyDictionaryLookup, JlptLevel } from '../../types';
type JlptLookup = (term: string) => JlptLevel | null;
export function createBuildDictionaryRootsMainHandler(deps: {
dirname: string;
appPath: string;
@@ -8,8 +12,7 @@ export function createBuildDictionaryRootsMainHandler(deps: {
cwd: string;
joinPath: (...parts: string[]) => string;
}) {
return () =>
[
return () => [
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'),
deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'),
@@ -57,7 +60,7 @@ export function createBuildJlptDictionaryRuntimeMainDepsHandler(deps: {
isJlptEnabled: () => boolean;
getDictionaryRoots: () => string[];
getJlptDictionarySearchPaths: (deps: { getDictionaryRoots: () => string[] }) => string[];
setJlptLevelLookup: (lookup: unknown) => void;
setJlptLevelLookup: (lookup: JlptLookup) => void;
logInfo: (message: string) => void;
}) {
return () => ({
@@ -66,7 +69,7 @@ export function createBuildJlptDictionaryRuntimeMainDepsHandler(deps: {
deps.getJlptDictionarySearchPaths({
getDictionaryRoots: () => deps.getDictionaryRoots(),
}),
setJlptLevelLookup: (lookup: unknown) => deps.setJlptLevelLookup(lookup),
setJlptLevelLookup: (lookup: JlptLookup) => deps.setJlptLevelLookup(lookup),
log: (message: string) => deps.logInfo(`[JLPT] ${message}`),
});
}
@@ -79,17 +82,19 @@ export function createBuildFrequencyDictionaryRuntimeMainDepsHandler(deps: {
getSourcePath: () => string | undefined;
}) => string[];
getSourcePath: () => string | undefined;
setFrequencyRankLookup: (lookup: unknown) => void;
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
logInfo: (message: string) => void;
}) {
return () => ({
isFrequencyDictionaryEnabled: () => deps.isFrequencyDictionaryEnabled(),
getSearchPaths: () =>
deps.getFrequencyDictionarySearchPaths({
getDictionaryRoots: () => deps.getDictionaryRoots().filter((dictionaryRoot) => dictionaryRoot),
getDictionaryRoots: () =>
deps.getDictionaryRoots().filter((dictionaryRoot) => dictionaryRoot),
getSourcePath: () => deps.getSourcePath(),
}),
setFrequencyRankLookup: (lookup: unknown) => deps.setFrequencyRankLookup(lookup),
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) =>
deps.setFrequencyRankLookup(lookup),
log: (message: string) => deps.logInfo(`[Frequency] ${message}`),
});
}

View File

@@ -8,7 +8,12 @@ test('field grouping overlay main deps builder maps window visibility and resolv
const resolver = (choice: unknown) => calls.push(`resolver:${choice}`);
const deps = createBuildFieldGroupingOverlayMainDepsHandler({
getMainWindow: () => ({ id: 'main' }),
getMainWindow: () => ({
isDestroyed: () => false,
webContents: {
send: () => {},
},
}),
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
@@ -24,7 +29,7 @@ test('field grouping overlay main deps builder maps window visibility and resolv
},
})();
assert.deepEqual(deps.getMainWindow(), { id: 'main' });
assert.equal(deps.getMainWindow()?.isDestroyed(), false);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getInvisibleOverlayVisible(), false);
assert.equal(deps.getResolver(), resolver);

View File

@@ -1,29 +1,34 @@
export function createBuildFieldGroupingOverlayMainDepsHandler<
TModal extends string,
TChoice,
>(deps: {
getMainWindow: () => unknown | null;
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
setVisibleOverlayVisible: (visible: boolean) => void;
setInvisibleOverlayVisible: (visible: boolean) => void;
getResolver: () => ((choice: TChoice) => void) | null;
setResolver: (resolver: ((choice: TChoice) => void) | null) => void;
getRestoreVisibleOverlayOnModalClose: () => Set<TModal>;
import type { FieldGroupingOverlayRuntimeOptions } from '../../core/services/field-grouping-overlay';
type FieldGroupingOverlayMainDeps<TModal extends string> = Omit<
FieldGroupingOverlayRuntimeOptions<TModal>,
'sendToVisibleOverlay'
> & {
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: TModal },
) => boolean;
}) {
return () => ({
getMainWindow: () => deps.getMainWindow() as never,
};
type BuiltFieldGroupingOverlayMainDeps<TModal extends string> =
FieldGroupingOverlayRuntimeOptions<TModal> & {
sendToVisibleOverlay: NonNullable<
FieldGroupingOverlayRuntimeOptions<TModal>['sendToVisibleOverlay']
>;
};
export function createBuildFieldGroupingOverlayMainDepsHandler<TModal extends string>(
deps: FieldGroupingOverlayMainDeps<TModal>,
) {
return (): BuiltFieldGroupingOverlayMainDeps<TModal> => ({
getMainWindow: () => deps.getMainWindow(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(),
setVisibleOverlayVisible: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
setInvisibleOverlayVisible: (visible: boolean) => deps.setInvisibleOverlayVisible(visible),
getResolver: () => deps.getResolver() as never,
setResolver: (resolver: ((choice: TChoice) => void) | null) => deps.setResolver(resolver),
getResolver: () => deps.getResolver(),
setResolver: (resolver) => deps.setResolver(resolver),
getRestoreVisibleOverlayOnModalClose: () => deps.getRestoreVisibleOverlayOnModalClose(),
sendToVisibleOverlay: (
channel: string,

View File

@@ -1,13 +1,19 @@
import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store';
import type { ResolvedConfig } from '../../types';
type ResolvedJellyfinConfig = ResolvedConfig['jellyfin'];
type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & {
accessToken?: string;
userId?: string;
};
export function createGetResolvedJellyfinConfigHandler(deps: {
getResolvedConfig: () => { jellyfin: unknown };
loadStoredSession: () => { accessToken: string; userId: string } | null | undefined;
getResolvedConfig: () => { jellyfin: ResolvedJellyfinConfig };
loadStoredSession: () => JellyfinStoredSession | null | undefined;
getEnv: (name: string) => string | undefined;
}) {
return () => {
const jellyfin = deps.getResolvedConfig().jellyfin as {
userId?: string;
[key: string]: unknown;
};
return (): ResolvedJellyfinConfigWithSession => {
const jellyfin = deps.getResolvedConfig().jellyfin;
const envToken = deps.getEnv('SUBMINER_JELLYFIN_ACCESS_TOKEN')?.trim() ?? '';
const envUserId = deps.getEnv('SUBMINER_JELLYFIN_USER_ID')?.trim() ?? '';
@@ -20,7 +26,7 @@ export function createGetResolvedJellyfinConfigHandler(deps: {
...jellyfin,
accessToken: envToken,
userId: envUserId || storedUserId || '',
} as never;
};
}
if (storedToken.length > 0 && storedUserId.length > 0) {
@@ -28,24 +34,20 @@ export function createGetResolvedJellyfinConfigHandler(deps: {
...jellyfin,
accessToken: storedToken,
userId: storedUserId,
} as never;
};
}
return jellyfin as never;
return jellyfin;
};
}
export function createGetJellyfinClientInfoHandler(deps: {
getResolvedJellyfinConfig: () => {
clientName?: string;
clientVersion?: string;
deviceId?: string;
};
getDefaultJellyfinConfig: () => {
clientName?: string;
clientVersion?: string;
deviceId?: string;
};
getResolvedJellyfinConfig: () => Partial<
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
>;
getDefaultJellyfinConfig: () => Partial<
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
>;
}) {
return (
config = deps.getResolvedJellyfinConfig(),

View File

@@ -6,12 +6,14 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
const calls: string[] = [];
const deps = createBuildPlayJellyfinItemInMpvMainDepsHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true }),
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'u',
mode: 'direct',
title: 't',
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => calls.push('defaults'),
sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`),
@@ -28,18 +30,57 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
assert.equal(await deps.ensureMpvConnectedForPlayback(), true);
assert.equal(typeof deps.getMpvClient(), 'object');
assert.deepEqual(
await deps.resolvePlaybackPlan({ session: {} as never, clientInfo: {} as never, jellyfinConfig: {}, itemId: 'i' }),
{ url: 'u', mode: 'direct', title: 't', startTimeTicks: 0 },
await deps.resolvePlaybackPlan({
session: {
serverUrl: 'http://localhost:8096',
accessToken: 'token',
userId: 'uid',
username: 'alice',
},
clientInfo: {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'did',
},
jellyfinConfig: {},
itemId: 'i',
}),
{
url: 'u',
mode: 'direct',
title: 't',
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
},
);
deps.applyJellyfinMpvDefaults({});
deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} });
deps.sendMpvCommand(['show-text', 'x']);
deps.armQuitOnDisconnect();
deps.schedule(() => {}, 500);
assert.equal(deps.convertTicksToSeconds(20_000_000), 2);
deps.preloadExternalSubtitles({ session: {} as never, clientInfo: {} as never, itemId: 'i' });
deps.preloadExternalSubtitles({
session: {
serverUrl: 'http://localhost:8096',
accessToken: 'token',
userId: 'uid',
username: 'alice',
},
clientInfo: {
clientName: 'SubMiner',
clientVersion: '1.0.0',
deviceId: 'did',
},
itemId: 'i',
});
deps.setActivePlayback({ itemId: 'i', mediaSourceId: undefined, playMethod: 'DirectPlay' });
deps.setLastProgressAtMs(0);
deps.reportPlaying({ itemId: 'i', mediaSourceId: undefined, playMethod: 'DirectPlay', eventName: 'start' });
deps.reportPlaying({
itemId: 'i',
mediaSourceId: undefined,
playMethod: 'DirectPlay',
eventName: 'start',
});
deps.showMpvOsd('ok');
assert.deepEqual(calls, [

View File

@@ -54,7 +54,7 @@ test('playback handler drives mpv commands and playback state', async () => {
const reportPayloads: Array<Record<string, unknown>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true }),
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',

View File

@@ -1,9 +1,6 @@
type JellyfinSession = {
serverUrl: string;
accessToken: string;
userId: string;
username: string;
};
import type { JellyfinAuthSession, JellyfinPlaybackPlan } from '../../core/services/jellyfin';
import type { JellyfinConfig } from '../../types';
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
type JellyfinClientInfo = {
clientName: string;
@@ -11,15 +8,6 @@ type JellyfinClientInfo = {
deviceId: string;
};
type JellyfinPlaybackPlan = {
url: string;
mode: 'direct' | 'transcode';
title: string;
startTimeTicks: number;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
};
type ActivePlaybackState = {
itemId: string;
mediaSourceId: undefined;
@@ -28,26 +16,24 @@ type ActivePlaybackState = {
playMethod: 'DirectPlay' | 'Transcode';
};
type MpvClientLike = unknown;
export function createPlayJellyfinItemInMpvHandler(deps: {
ensureMpvConnectedForPlayback: () => Promise<boolean>;
getMpvClient: () => MpvClientLike | null;
getMpvClient: () => MpvRuntimeClientLike | null;
resolvePlaybackPlan: (params: {
session: JellyfinSession;
session: JellyfinAuthSession;
clientInfo: JellyfinClientInfo;
jellyfinConfig: unknown;
jellyfinConfig: JellyfinConfig;
itemId: string;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
}) => Promise<JellyfinPlaybackPlan>;
applyJellyfinMpvDefaults: (mpvClient: MpvClientLike) => void;
applyJellyfinMpvDefaults: (mpvClient: MpvRuntimeClientLike) => void;
sendMpvCommand: (command: Array<string | number>) => void;
armQuitOnDisconnect: () => void;
schedule: (callback: () => void, delayMs: number) => void;
convertTicksToSeconds: (ticks: number) => number;
preloadExternalSubtitles: (params: {
session: JellyfinSession;
session: JellyfinAuthSession;
clientInfo: JellyfinClientInfo;
itemId: string;
}) => void;
@@ -64,9 +50,9 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
showMpvOsd: (text: string) => void;
}) {
return async (params: {
session: JellyfinSession;
session: JellyfinAuthSession;
clientInfo: JellyfinClientInfo;
jellyfinConfig: unknown;
jellyfinConfig: JellyfinConfig;
itemId: string;
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
@@ -96,7 +82,11 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect();
}
deps.sendMpvCommand(['set_property', 'force-media-title', `[Jellyfin/${plan.mode}] ${plan.title}`]);
deps.sendMpvCommand([
'set_property',
'force-media-title',
`[Jellyfin/${plan.mode}] ${plan.title}`,
]);
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.schedule(() => {
deps.sendMpvCommand(['set_property', 'sid', 'no']);

View File

@@ -1,5 +1,7 @@
import type { Config } from '../../types';
export type MpvClientRuntimeServiceOptions = {
getResolvedConfig: () => unknown;
getResolvedConfig: () => Config;
autoStartOverlay: boolean;
setOverlayVisible: (visible: boolean) => void;
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;

View File

@@ -12,7 +12,7 @@ test('apply jellyfin mpv defaults main deps builder maps callbacks', () => {
jellyfinLangPref: 'ja,jp',
})();
deps.sendMpvCommandRuntime({}, ['set_property', 'aid', 'auto']);
deps.sendMpvCommandRuntime({ connected: true, send: () => {} }, ['set_property', 'aid', 'auto']);
assert.equal(deps.jellyfinLangPref, 'ja,jp');
assert.deepEqual(calls, ['set_property:aid:auto']);
});

View File

@@ -12,7 +12,7 @@ test('apply jellyfin mpv defaults sends expected property commands', () => {
jellyfinLangPref: 'ja,jp',
});
applyDefaults({});
applyDefaults({ connected: true, send: () => {} });
assert.deepEqual(calls, [
'set_property:sub-auto:fuzzy',
'set_property:aid:auto',

View File

@@ -1,13 +1,10 @@
type MpvClientLike = unknown;
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
export function createApplyJellyfinMpvDefaultsHandler(deps: {
sendMpvCommandRuntime: (
client: MpvClientLike,
command: [string, string, string],
) => void;
sendMpvCommandRuntime: (client: MpvRuntimeClientLike, command: [string, string, string]) => void;
jellyfinLangPref: string;
}) {
return (client: MpvClientLike): void => {
return (client: MpvRuntimeClientLike): void => {
deps.sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']);
deps.sendMpvCommandRuntime(client, ['set_property', 'aid', 'auto']);
deps.sendMpvCommandRuntime(client, ['set_property', 'sid', 'auto']);
@@ -18,9 +15,7 @@ export function createApplyJellyfinMpvDefaultsHandler(deps: {
};
}
export function createGetDefaultSocketPathHandler(deps: {
platform: string;
}) {
export function createGetDefaultSocketPathHandler(deps: { platform: string }) {
return (): string => {
if (deps.platform === 'win32') {
return '\\\\.\\pipe\\subminer-socket';

View File

@@ -15,9 +15,7 @@ import {
createHandleMpvTimePosChangeHandler,
} from './mpv-main-event-actions';
type MpvEventClient = {
on: (...args: any[]) => unknown;
};
type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandlers>>[0];
export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void;
@@ -119,7 +117,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
updateSubtitleRenderMetrics: (patch) => deps.updateSubtitleRenderMetrics(patch),
});
const handleMpvSecondarySubtitleVisibility = createHandleMpvSecondarySubtitleVisibilityHandler({
setPreviousSecondarySubVisibility: (visible) => deps.setPreviousSecondarySubVisibility(visible),
setPreviousSecondarySubVisibility: (visible) =>
deps.setPreviousSecondarySubVisibility(visible),
});
createBindMpvClientEventHandlers({
@@ -134,6 +133,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
onPauseChange: handleMpvPauseChange,
onSubtitleMetricsChange: handleMpvSubtitleMetricsChange,
onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility,
})(mpvClient as never);
})(mpvClient);
};
}

View File

@@ -32,7 +32,10 @@ test('append to mpv log main deps map filesystem functions and log path', async
test('show mpv osd main deps map runtime delegates and logging callback', () => {
const calls: string[] = [];
const client = { id: 'mpv' };
const client = {
connected: true,
send: () => {},
};
const deps = createBuildShowMpvOsdMainDepsHandler({
appendToMpvLog: (message) => calls.push(`append:${message}`),
showMpvOsdRuntime: (_mpvClient, text, fallbackLog) => {

View File

@@ -1,11 +1,10 @@
export function createBuildAppendToMpvLogMainDepsHandler(deps: {
logPath: string;
dirname: (targetPath: string) => string;
mkdir: (targetPath: string, options: { recursive: boolean }) => Promise<void>;
appendFile: (targetPath: string, data: string, options: { encoding: 'utf8' }) => Promise<void>;
now: () => Date;
}) {
return () => ({
import type { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './mpv-osd-log';
type AppendToMpvLogMainDeps = Parameters<typeof createAppendToMpvLogHandler>[0];
type ShowMpvOsdMainDeps = Parameters<typeof createShowMpvOsdHandler>[0];
export function createBuildAppendToMpvLogMainDepsHandler(deps: AppendToMpvLogMainDeps) {
return (): AppendToMpvLogMainDeps => ({
logPath: deps.logPath,
dirname: (targetPath: string) => deps.dirname(targetPath),
mkdir: (targetPath: string, options: { recursive: boolean }) => deps.mkdir(targetPath, options),
@@ -15,24 +14,12 @@ export function createBuildAppendToMpvLogMainDepsHandler(deps: {
});
}
export function createBuildShowMpvOsdMainDepsHandler(deps: {
appendToMpvLog: (message: string) => void;
showMpvOsdRuntime: (
mpvClient: unknown | null,
text: string,
fallbackLog: (line: string) => void,
) => void;
getMpvClient: () => unknown | null;
logInfo: (line: string) => void;
}) {
return () => ({
export function createBuildShowMpvOsdMainDepsHandler(deps: ShowMpvOsdMainDeps) {
return (): ShowMpvOsdMainDeps => ({
appendToMpvLog: (message: string) => deps.appendToMpvLog(message),
showMpvOsdRuntime: (
mpvClient: unknown | null,
text: string,
fallbackLog: (line: string) => void,
) => deps.showMpvOsdRuntime(mpvClient, text, fallbackLog),
getMpvClient: () => deps.getMpvClient() as never,
showMpvOsdRuntime: (mpvClient, text, fallbackLog) =>
deps.showMpvOsdRuntime(mpvClient, text, fallbackLog),
getMpvClient: () => deps.getMpvClient(),
logInfo: (line: string) => deps.logInfo(line),
});
}

View File

@@ -1,15 +1,17 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { BaseWindowTracker } from '../../window-trackers';
import type { KikuFieldGroupingChoice } from '../../types';
import { createOverlayRuntimeBootstrapHandlers } from './overlay-runtime-bootstrap-handlers';
test('overlay runtime bootstrap handlers compose options builder and bootstrap handler', () => {
const appState = {
backendOverride: null as string | null,
windowTracker: null as unknown,
windowTracker: null as BaseWindowTracker | null,
subtitleTimingTracker: null as unknown,
mpvClient: null as unknown,
mpvClient: null,
mpvSocketPath: '/tmp/mpv.sock',
runtimeOptionsManager: null as unknown,
runtimeOptionsManager: null,
ankiIntegration: null as unknown,
};
let initialized = false;
@@ -39,7 +41,13 @@ test('overlay runtime bootstrap handlers compose options builder and bootstrap h
getOverlayWindows: () => [],
getResolvedConfig: () => ({}),
showDesktopNotification: () => {},
createFieldGroupingCallback: () => (async () => 'combined' as never),
createFieldGroupingCallback: () => async () =>
({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: true,
}) as KikuFieldGroupingChoice,
getKnownWordCacheStatePath: () => '/tmp/known.json',
},
initializeOverlayRuntimeBootstrapDeps: {

View File

@@ -6,15 +6,22 @@ import { createBuildInitializeOverlayRuntimeMainDepsHandler } from './overlay-ru
type InitializeOverlayRuntimeMainDeps = Parameters<
typeof createBuildInitializeOverlayRuntimeMainDepsHandler
>[0];
type InitializeOverlayRuntimeOptions = ReturnType<
ReturnType<typeof createBuildInitializeOverlayRuntimeOptionsHandler>
>;
type InitializeOverlayRuntimeBootstrapMainDeps = Parameters<
typeof createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler
typeof createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<InitializeOverlayRuntimeOptions>
>[0];
export function createOverlayRuntimeBootstrapHandlers(deps: {
initializeOverlayRuntimeMainDeps: InitializeOverlayRuntimeMainDeps;
initializeOverlayRuntimeBootstrapDeps: Omit<InitializeOverlayRuntimeBootstrapMainDeps, 'buildOptions'>;
initializeOverlayRuntimeBootstrapDeps: Omit<
InitializeOverlayRuntimeBootstrapMainDeps,
'buildOptions'
>;
}) {
const buildInitializeOverlayRuntimeOptionsHandler = createBuildInitializeOverlayRuntimeOptionsHandler(
const buildInitializeOverlayRuntimeOptionsHandler =
createBuildInitializeOverlayRuntimeOptionsHandler(
createBuildInitializeOverlayRuntimeMainDepsHandler(deps.initializeOverlayRuntimeMainDeps)(),
);
const initializeOverlayRuntime = createInitializeOverlayRuntimeHandler(

View File

@@ -1,12 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { BaseWindowTracker } from '../../window-trackers';
import { createBuildInitializeOverlayRuntimeMainDepsHandler } from './overlay-runtime-options-main-deps';
test('overlay runtime main deps builder maps runtime state and callbacks', () => {
const calls: string[] = [];
const appState = {
backendOverride: 'x11' as string | null,
windowTracker: null as unknown,
windowTracker: null as BaseWindowTracker | null,
subtitleTimingTracker: { id: 'tracker' } as unknown,
mpvClient: null as { send?: (payload: { command: string[] }) => void } | null,
mpvSocketPath: '/tmp/mpv.sock',
@@ -36,7 +37,12 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
getOverlayWindows: () => [],
getResolvedConfig: () => ({}),
showDesktopNotification: () => calls.push('notify'),
createFieldGroupingCallback: () => async () => ({ cancelled: true }),
createFieldGroupingCallback: () => async () => ({
keepNoteId: 1,
deleteNoteId: 2,
deleteDuplicate: false,
cancelled: true,
}),
getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json',
});
@@ -58,7 +64,11 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
deps.syncOverlayShortcuts();
deps.showDesktopNotification('title', {});
deps.setWindowTracker({ id: 'tracker' });
const tracker = {
close: () => {},
getWindowGeometry: () => null,
} as unknown as BaseWindowTracker;
deps.setWindowTracker(tracker);
deps.setAnkiIntegration({ id: 'anki' });
assert.deepEqual(calls, [
@@ -72,6 +82,6 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
'sync-shortcuts',
'notify',
]);
assert.deepEqual(appState.windowTracker, { id: 'tracker' });
assert.equal(appState.windowTracker, tracker);
assert.deepEqual(appState.ankiIntegration, { id: 'anki' });
});

View File

@@ -1,14 +1,19 @@
import type { AnkiConnectConfig } from '../../types';
import type { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options';
type OverlayRuntimeOptionsMainDeps = Parameters<
typeof createBuildInitializeOverlayRuntimeOptionsHandler
>[0];
export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
appState: {
backendOverride: string | null;
windowTracker: unknown | null;
subtitleTimingTracker: unknown | null;
mpvClient: unknown | null;
windowTracker: Parameters<OverlayRuntimeOptionsMainDeps['setWindowTracker']>[0];
subtitleTimingTracker: ReturnType<OverlayRuntimeOptionsMainDeps['getSubtitleTimingTracker']>;
mpvClient: ReturnType<OverlayRuntimeOptionsMainDeps['getMpvClient']>;
mpvSocketPath: string;
runtimeOptionsManager: unknown | null;
ankiIntegration: unknown | null;
runtimeOptionsManager: ReturnType<OverlayRuntimeOptionsMainDeps['getRuntimeOptionsManager']>;
ankiIntegration: Parameters<OverlayRuntimeOptionsMainDeps['setAnkiIntegration']>[0];
};
overlayManager: {
getVisibleOverlayVisible: () => boolean;
@@ -25,27 +30,36 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
createMainWindow: () => void;
createInvisibleWindow: () => void;
registerGlobalShortcuts: () => void;
updateVisibleOverlayBounds: (geometry: { x: number; y: number; width: number; height: number }) => void;
updateVisibleOverlayBounds: (geometry: {
x: number;
y: number;
width: number;
height: number;
}) => void;
updateInvisibleOverlayBounds: (geometry: {
x: number;
y: number;
width: number;
height: number;
}) => void;
getOverlayWindows: () => unknown[];
getOverlayWindows: OverlayRuntimeOptionsMainDeps['getOverlayWindows'];
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
createFieldGroupingCallback: () => unknown;
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string;
}) {
return () => ({
return (): OverlayRuntimeOptionsMainDeps => ({
getBackendOverride: () => deps.appState.backendOverride,
getInitialInvisibleOverlayVisibility: () => deps.getInitialInvisibleOverlayVisibility(),
createMainWindow: () => deps.createMainWindow(),
createInvisibleWindow: () => deps.createInvisibleWindow(),
registerGlobalShortcuts: () => deps.registerGlobalShortcuts(),
updateVisibleOverlayBounds: (geometry: { x: number; y: number; width: number; height: number }) =>
deps.updateVisibleOverlayBounds(geometry),
updateVisibleOverlayBounds: (geometry: {
x: number;
y: number;
width: number;
height: number;
}) => deps.updateVisibleOverlayBounds(geometry),
updateInvisibleOverlayBounds: (geometry: {
x: number;
y: number;
@@ -54,28 +68,25 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
}) => deps.updateInvisibleOverlayBounds(geometry),
isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(),
isInvisibleOverlayVisible: () => deps.overlayManager.getInvisibleOverlayVisible(),
updateVisibleOverlayVisibility: () => deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
updateVisibleOverlayVisibility: () =>
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
updateInvisibleOverlayVisibility: () =>
deps.overlayVisibilityRuntime.updateInvisibleOverlayVisibility(),
getOverlayWindows: () => deps.getOverlayWindows() as never,
getOverlayWindows: () => deps.getOverlayWindows(),
syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(),
setWindowTracker: (tracker: unknown | null) => {
setWindowTracker: (tracker) => {
deps.appState.windowTracker = tracker;
},
getResolvedConfig: () => deps.getResolvedConfig(),
getSubtitleTimingTracker: () => deps.appState.subtitleTimingTracker,
getMpvClient: () =>
(deps.appState.mpvClient as { send?: (payload: { command: string[] }) => void } | null),
getMpvClient: () => deps.appState.mpvClient,
getMpvSocketPath: () => deps.appState.mpvSocketPath,
getRuntimeOptionsManager: () =>
deps.appState.runtimeOptionsManager as
| { getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig }
| null,
setAnkiIntegration: (integration: unknown | null) => {
getRuntimeOptionsManager: () => deps.appState.runtimeOptionsManager,
setAnkiIntegration: (integration) => {
deps.appState.ankiIntegration = integration;
},
showDesktopNotification: deps.showDesktopNotification,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback() as never,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
});
}

View File

@@ -5,6 +5,7 @@ import type {
WindowGeometry,
} from '../../types';
import type { BrowserWindow } from 'electron';
import type { BaseWindowTracker } from '../../window-trackers';
type OverlayRuntimeOptions = {
backendOverride: string | null;
@@ -20,7 +21,7 @@ type OverlayRuntimeOptions = {
updateInvisibleOverlayVisibility: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: unknown | null) => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
@@ -50,7 +51,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
updateInvisibleOverlayVisibility: () => void;
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: unknown | null) => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;

View File

@@ -1,3 +1,5 @@
import type { BaseWindowTracker } from '../../window-trackers';
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBuildOverlayVisibilityRuntimeMainDepsHandler } from './overlay-visibility-runtime-main-deps';
@@ -7,13 +9,14 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
let trackerNotReadyWarningShown = false;
const mainWindow = { id: 'main' } as never;
const invisibleWindow = { id: 'invisible' } as never;
const tracker = { id: 'tracker' } as unknown as BaseWindowTracker;
const deps = createBuildOverlayVisibilityRuntimeMainDepsHandler({
getMainWindow: () => mainWindow,
getInvisibleWindow: () => invisibleWindow,
getVisibleOverlayVisible: () => true,
getInvisibleOverlayVisible: () => false,
getWindowTracker: () => ({ id: 'tracker' }),
getWindowTracker: () => tracker,
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
setTrackerNotReadyWarningShown: (shown) => {
trackerNotReadyWarningShown = shown;

View File

@@ -2,29 +2,19 @@ import type { BrowserWindow } from 'electron';
import type { WindowGeometry } from '../../types';
import type { OverlayVisibilityRuntimeDeps } from '../overlay-visibility-runtime';
export function createBuildOverlayVisibilityRuntimeMainDepsHandler(deps: {
getMainWindow: () => BrowserWindow | null;
getInvisibleWindow: () => BrowserWindow | null;
getVisibleOverlayVisible: () => boolean;
getInvisibleOverlayVisible: () => boolean;
getWindowTracker: () => unknown | null;
getTrackerNotReadyWarningShown: () => boolean;
setTrackerNotReadyWarningShown: (shown: boolean) => void;
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
updateInvisibleOverlayBounds: (geometry: WindowGeometry) => void;
ensureOverlayWindowLevel: (window: BrowserWindow) => void;
enforceOverlayLayerOrder: () => void;
syncOverlayShortcuts: () => void;
}) {
export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
deps: OverlayVisibilityRuntimeDeps,
) {
return (): OverlayVisibilityRuntimeDeps => ({
getMainWindow: () => deps.getMainWindow(),
getInvisibleWindow: () => deps.getInvisibleWindow(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getInvisibleOverlayVisible: () => deps.getInvisibleOverlayVisible(),
getWindowTracker: () => deps.getWindowTracker() as never,
getWindowTracker: () => deps.getWindowTracker(),
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
updateVisibleOverlayBounds: (geometry: WindowGeometry) => deps.updateVisibleOverlayBounds(geometry),
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateVisibleOverlayBounds(geometry),
updateInvisibleOverlayBounds: (geometry: WindowGeometry) =>
deps.updateInvisibleOverlayBounds(geometry),
ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window),

View File

@@ -8,7 +8,7 @@ import {
test('reload config main deps builder maps callbacks and fail handlers', async () => {
const calls: string[] = [];
const deps = createBuildReloadConfigMainDepsHandler({
reloadConfigStrict: () => ({ ok: true }),
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: (message) => calls.push(`info:${message}`),
logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
@@ -24,7 +24,11 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
},
})();
assert.deepEqual(deps.reloadConfigStrict(), { ok: true });
assert.deepEqual(deps.reloadConfigStrict(), {
ok: true,
path: '/tmp/config.jsonc',
warnings: [],
});
deps.logInfo('x');
deps.logWarning('y');
deps.showDesktopNotification('SubMiner', { body: 'warn' });

View File

@@ -1,18 +1,11 @@
export function createBuildReloadConfigMainDepsHandler(deps: {
reloadConfigStrict: () => unknown;
logInfo: (message: string) => void;
logWarning: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
startConfigHotReload: () => void;
refreshAnilistClientSecretState: (options: { force: boolean }) => Promise<unknown>;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
quit: () => void;
};
}) {
return () => ({
reloadConfigStrict: () => deps.reloadConfigStrict() as never,
import type { createCriticalConfigErrorHandler, createReloadConfigHandler } from './startup-config';
type ReloadConfigMainDeps = Parameters<typeof createReloadConfigHandler>[0];
type CriticalConfigErrorMainDeps = Parameters<typeof createCriticalConfigErrorHandler>[0];
export function createBuildReloadConfigMainDepsHandler(deps: ReloadConfigMainDeps) {
return (): ReloadConfigMainDeps => ({
reloadConfigStrict: () => deps.reloadConfigStrict(),
logInfo: (message: string) => deps.logInfo(message),
logWarning: (message: string) => deps.logWarning(message),
showDesktopNotification: (title: string, options: { body: string }) =>
@@ -22,25 +15,20 @@ export function createBuildReloadConfigMainDepsHandler(deps: {
deps.refreshAnilistClientSecretState(options),
failHandlers: {
logError: (details: string) => deps.failHandlers.logError(details),
showErrorBox: (title: string, details: string) => deps.failHandlers.showErrorBox(title, details),
showErrorBox: (title: string, details: string) =>
deps.failHandlers.showErrorBox(title, details),
quit: () => deps.failHandlers.quit(),
},
});
}
export function createBuildCriticalConfigErrorMainDepsHandler(deps: {
getConfigPath: () => string;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
quit: () => void;
};
}) {
return () => ({
export function createBuildCriticalConfigErrorMainDepsHandler(deps: CriticalConfigErrorMainDeps) {
return (): CriticalConfigErrorMainDeps => ({
getConfigPath: () => deps.getConfigPath(),
failHandlers: {
logError: (details: string) => deps.failHandlers.logError(details),
showErrorBox: (title: string, details: string) => deps.failHandlers.showErrorBox(title, details),
showErrorBox: (title: string, details: string) =>
deps.failHandlers.showErrorBox(title, details),
quit: () => deps.failHandlers.quit(),
},
});

View File

@@ -9,8 +9,8 @@ import {
test('tokenizer deps builder records known-word lookups and maps readers', () => {
const calls: string[] = [];
const deps = createBuildTokenizerDepsMainHandler({
getYomitanExt: () => ({ id: 'ext' }),
getYomitanParserWindow: () => ({ id: 'window' }),
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => calls.push('set-window'),
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => calls.push('set-ready'),
@@ -18,22 +18,22 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
setYomitanParserInitPromise: () => calls.push('set-init'),
isKnownWord: (text) => text === 'known',
recordLookup: (hit) => calls.push(`lookup:${hit}`),
getKnownWordMatchMode: () => 'exact',
getKnownWordMatchMode: () => 'surface',
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => 'N2',
getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: () => 5,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => ({ id: 'mecab' }),
getMecabTokenizer: () => null,
})();
assert.equal(deps.isKnownWord('known'), true);
assert.equal(deps.isKnownWord('unknown'), false);
deps.setYomitanParserWindow({});
deps.setYomitanParserWindow(null);
deps.setYomitanParserReadyPromise(null);
deps.setYomitanParserInitPromise(null);
assert.equal(deps.getMinSentenceWordsForNPlusOne(), 3);
assert.equal(deps.getMinSentenceWordsForNPlusOne?.(), 3);
assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']);
});

View File

@@ -1,30 +1,29 @@
export function createBuildTokenizerDepsMainHandler(deps: {
getYomitanExt: () => unknown;
getYomitanParserWindow: () => unknown;
setYomitanParserWindow: (window: unknown) => void;
getYomitanParserReadyPromise: () => Promise<void> | null;
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string) => boolean;
import type { TokenizerDepsRuntimeOptions } from '../../core/services/tokenizer';
type TokenizerMainDeps = TokenizerDepsRuntimeOptions & {
getJlptEnabled: NonNullable<TokenizerDepsRuntimeOptions['getJlptEnabled']>;
getFrequencyDictionaryEnabled: NonNullable<
TokenizerDepsRuntimeOptions['getFrequencyDictionaryEnabled']
>;
getFrequencyRank: NonNullable<TokenizerDepsRuntimeOptions['getFrequencyRank']>;
getMinSentenceWordsForNPlusOne: NonNullable<
TokenizerDepsRuntimeOptions['getMinSentenceWordsForNPlusOne']
>;
getYomitanGroupDebugEnabled: NonNullable<
TokenizerDepsRuntimeOptions['getYomitanGroupDebugEnabled']
>;
recordLookup: (hit: boolean) => void;
getKnownWordMatchMode: () => unknown;
getMinSentenceWordsForNPlusOne: () => number;
getJlptLevel: (text: string) => unknown;
getJlptEnabled: () => boolean;
getFrequencyDictionaryEnabled: () => boolean;
getFrequencyRank: (text: string) => unknown;
getYomitanGroupDebugEnabled: () => boolean;
getMecabTokenizer: () => unknown;
}) {
return () => ({
getYomitanExt: () => deps.getYomitanExt() as never,
getYomitanParserWindow: () => deps.getYomitanParserWindow() as never,
setYomitanParserWindow: (window: unknown) => deps.setYomitanParserWindow(window),
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise() as never,
};
export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
return (): TokenizerDepsRuntimeOptions => ({
getYomitanExt: () => deps.getYomitanExt(),
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
setYomitanParserWindow: (window) => deps.setYomitanParserWindow(window),
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
setYomitanParserReadyPromise: (promise: Promise<void> | null) =>
deps.setYomitanParserReadyPromise(promise),
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise() as never,
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
setYomitanParserInitPromise: (promise: Promise<boolean> | null) =>
deps.setYomitanParserInitPromise(promise),
isKnownWord: (text: string) => {
@@ -32,14 +31,14 @@ export function createBuildTokenizerDepsMainHandler(deps: {
deps.recordLookup(hit);
return hit;
},
getKnownWordMatchMode: () => deps.getKnownWordMatchMode() as never,
getKnownWordMatchMode: () => deps.getKnownWordMatchMode(),
getMinSentenceWordsForNPlusOne: () => deps.getMinSentenceWordsForNPlusOne(),
getJlptLevel: (text: string) => deps.getJlptLevel(text) as never,
getJlptLevel: (text: string) => deps.getJlptLevel(text),
getJlptEnabled: () => deps.getJlptEnabled(),
getFrequencyDictionaryEnabled: () => deps.getFrequencyDictionaryEnabled(),
getFrequencyRank: (text: string) => deps.getFrequencyRank(text) as never,
getFrequencyRank: (text: string) => deps.getFrequencyRank(text),
getYomitanGroupDebugEnabled: () => deps.getYomitanGroupDebugEnabled(),
getMecabTokenizer: () => deps.getMecabTokenizer() as never,
getMecabTokenizer: () => deps.getMecabTokenizer(),
});
}

View File

@@ -1,22 +1,34 @@
import { createDestroyTrayHandler, createEnsureTrayHandler } from './tray-lifecycle';
import { createBuildDestroyTrayMainDepsHandler, createBuildEnsureTrayMainDepsHandler } from './app-runtime-main-deps';
import { createBuildTrayMenuTemplateHandler, createResolveTrayIconPathHandler } from './tray-main-actions';
import {
createBuildDestroyTrayMainDepsHandler,
createBuildEnsureTrayMainDepsHandler,
} from './app-runtime-main-deps';
import {
createBuildTrayMenuTemplateHandler,
createResolveTrayIconPathHandler,
} from './tray-main-actions';
import {
createBuildResolveTrayIconPathMainDepsHandler,
createBuildTrayMenuTemplateMainDepsHandler,
} from './tray-main-deps';
type ResolveTrayIconPathMainDeps = Parameters<typeof createBuildResolveTrayIconPathMainDepsHandler>[0];
type ResolveTrayIconPathMainDeps = Parameters<
typeof createBuildResolveTrayIconPathMainDepsHandler
>[0];
type BuildTrayMenuTemplateMainDeps<TMenuItem> = Parameters<
typeof createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>
>[0];
type EnsureTrayMainDeps = Parameters<typeof createBuildEnsureTrayMainDepsHandler>[0];
type DestroyTrayMainDeps = Parameters<typeof createBuildDestroyTrayMainDepsHandler>[0];
type EnsureTrayMainDeps<TTrayMenu> = Parameters<
typeof createBuildEnsureTrayMainDepsHandler<TrayLike, TTrayMenu, TrayIconLike>
>[0];
type TrayLike = NonNullable<ReturnType<Parameters<typeof createEnsureTrayHandler>[0]['getTray']>>;
type TrayIconLike = Parameters<Parameters<typeof createEnsureTrayHandler>[0]['createTray']>[0];
type DestroyTrayMainDeps = Parameters<typeof createBuildDestroyTrayMainDepsHandler<TrayLike>>[0];
export function createTrayRuntimeHandlers<TMenuItem, TMenu>(deps: {
resolveTrayIconPathDeps: ResolveTrayIconPathMainDeps;
buildTrayMenuTemplateDeps: BuildTrayMenuTemplateMainDeps<TMenuItem>;
ensureTrayDeps: Omit<EnsureTrayMainDeps, 'buildTrayMenu' | 'resolveTrayIconPath'>;
ensureTrayDeps: Omit<EnsureTrayMainDeps<TMenu>, 'buildTrayMenu' | 'resolveTrayIconPath'>;
destroyTrayDeps: DestroyTrayMainDeps;
buildMenuFromTemplate: (template: TMenuItem[]) => TMenu;
}) {