mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-01 18:12:06 -07:00
Compare commits
4 Commits
main
...
refactor-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
564a295e5f
|
|||
|
006ff22d42
|
|||
|
ec64eebb80
|
|||
|
983f3b38ee
|
@@ -109,7 +109,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
||||
## Requirements
|
||||
|
||||
| | Required | Optional |
|
||||
| -------------- | --------------------------------------- | -------------------------------------- |
|
||||
| -------------- | --------------------------------------- | ---------------------------------------------------------- |
|
||||
| **Player** | [`mpv`](https://mpv.io) with IPC socket | — |
|
||||
| **Processing** | `ffmpeg`, `mecab` + `mecab-ipadic` | `guessit` (AniSkip), `alass` / `ffsubsync` (subtitle sync) |
|
||||
| **Media** | — | `yt-dlp`, `chafa`, `ffmpegthumbnailer` |
|
||||
@@ -236,8 +236,6 @@ subminer stats -b # stats daemon in background
|
||||
subminer stats -s # stop background stats daemon
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Full guides on configuration, Anki setup, Jellyfin, immersion tracking, and more: **[docs.subminer.moe](https://docs.subminer.moe)**
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: TASK-238.8
|
||||
title: Refactor src/main.ts composition root into domain runtimes
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-03-31 06:28'
|
||||
updated_date: '2026-04-01 07:07'
|
||||
labels:
|
||||
- tech-debt
|
||||
- runtime
|
||||
- maintainability
|
||||
- composition-root
|
||||
dependencies: []
|
||||
references:
|
||||
- src/main.ts
|
||||
- src/main/boot/services
|
||||
- src/main/runtime/composers
|
||||
- docs/architecture/README.md
|
||||
parent_task_id: TASK-238
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Refactor `src/main.ts` so it becomes a thin composition root and the domain-specific runtime wiring moves into short wrapper modules under `src/main/`. Preserve all current behavior, IPC contracts, and config/schema semantics while reducing the entrypoint to boot services, grouped runtime instantiation, startup execution, and process-level quit handling.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 `src/main.ts` is bootstrap/composition only: platform preflight, boot services, runtime creation, startup execution, and top-level quit/signal handling.
|
||||
- [ ] #2 `src/main.ts` no longer imports `src/main/runtime/*-main-deps.ts` directly.
|
||||
- [ ] #3 `src/main.ts` has no local names like `build*MainDepsHandler`, `*MainDeps`, or trivial `*Handler` pass-through wrappers.
|
||||
- [ ] #4 New wrapper files stay under ~500 LOC each; if one exceeds that, split before merge.
|
||||
- [ ] #5 Cross-domain coordination stays in `main.ts`; wrapper modules stay acyclic and communicate via injected callbacks.
|
||||
- [ ] #6 No user-facing behavior, config fields, or IPC channel names change.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
CI follow-up: typecheck failed after the runtime split because playlist-browser IPC deps were not threaded through the new bootstrap/composer surfaces. Wiring the missing open action and registration deps now.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
id: TASK-262
|
||||
title: Create overlay UI bootstrap input helper
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-03-31 17:04'
|
||||
labels:
|
||||
- refactor
|
||||
- main
|
||||
- overlay-ui
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add a coarse input-builder/helper module to reduce the large createOverlayUiRuntime(...) callsite in src/main.ts without changing runtime behavior. Do not edit src/main.ts in this task.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 New helper module(s) live under src/main/
|
||||
- [ ] #2 Helper accepts grouped overlay UI/domain inputs instead of giant inline literals
|
||||
- [ ] #3 Helper keeps files under 500 LOC
|
||||
- [ ] #4 Optional focused tests added if useful
|
||||
- [ ] #5 No runtime behavior changes
|
||||
<!-- AC:END -->
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
id: TASK-263
|
||||
title: Create coarse startup bootstrap wrapper
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-03-31 17:21'
|
||||
labels:
|
||||
- refactor
|
||||
- main
|
||||
- startup
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Move the large createMainStartupRuntime construction and its self-reference handling out of src/main.ts into a coarse startup bootstrap wrapper. Keep behavior identical and shrink the startup section materially.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 New wrapper module(s) live under src/main/
|
||||
- [ ] #2 Wrapper owns createMainStartupRuntime construction and self-reference handling
|
||||
- [ ] #3 src/main.ts startup section becomes materially smaller
|
||||
- [ ] #4 Files stay under 500 LOC
|
||||
- [ ] #5 Focused tests cover the wrapper if useful
|
||||
- [ ] #6 No runtime behavior changes
|
||||
<!-- AC:END -->
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
id: TASK-263
|
||||
title: Reuse pre-add duplicate IDs for generic Kiku field grouping
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-31 20:44'
|
||||
updated_date: '2026-03-31 20:48'
|
||||
labels:
|
||||
- anki
|
||||
- kiku
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Avoid the extra post-add duplicate lookup on the generic sentence-card creation path by capturing duplicate note IDs before add and reusing that result for Kiku field grouping. Keep Yomitan semantics aligned where practical so duplicate selection is consistent across mining paths.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Generic sentence-card creation captures duplicate note IDs before add and reuses them for Kiku field grouping instead of running the existing post-add duplicate finder
|
||||
- [x] #2 Duplicate selection remains deterministic when multiple matching notes exist
|
||||
- [x] #3 Regression tests cover the generic path duplicate reuse behavior and preserve existing non-Kiku behavior
|
||||
- [x] #4 Internal docs/config comments are updated if the behavior or operator-facing semantics changed
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
No docs update was required because this is internal duplicate-selection plumbing and does not change user-facing config surface.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Generic sentence-card creation now captures exact duplicate note IDs before add when Kiku field grouping is enabled and stores that context by created note ID. Manual field grouping reuses the tracked duplicate IDs first and deterministically picks the most recent matching note, falling back to the legacy duplicate finder only when no tracked context exists. Verified with bun test src/anki-integration/duplicate.test.ts src/anki-integration/card-creation.test.ts src/anki-integration/field-grouping.test.ts and bun run typecheck.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
id: TASK-263.1
|
||||
title: Reuse Yomitan popup duplicate IDs in SubMiner bridge
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-31 22:15'
|
||||
updated_date: '2026-03-31 22:21'
|
||||
labels:
|
||||
- anki
|
||||
- kiku
|
||||
- yomitan
|
||||
dependencies: []
|
||||
parent_task_id: TASK-263
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Thread Yomitan popup/search duplicate note IDs through the existing SubMiner bridge so Kiku/manual grouping can reuse the same duplicate context that already drives the Add duplicate button. Implement and test against the vendored Yomitan copy first; do not rely on upstreamed fork changes yet.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Vendored Yomitan bridge returns duplicate note IDs for popup/search mining when available
|
||||
- [x] #2 SubMiner consumes the bridged duplicate IDs and prefers them for Kiku/manual grouping on the Yomitan mining path
|
||||
- [x] #3 Regression tests cover the popup/search bridge payload and duplicate-id reuse behavior
|
||||
- [x] #4 No commit is made for vendored Yomitan-only changes in this repo state
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Vendored files changed locally for validation only: vendor/subminer-yomitan/ext/js/display/display-anki.js, vendor/subminer-yomitan/ext/js/comm/api.js, vendor/subminer-yomitan/ext/js/comm/anki-connect.js, vendor/subminer-yomitan/ext/js/background/backend.js. Do not commit those vendor changes in this repo; port them to the fork instead.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Vendored Yomitan popup/search mining now precomputes duplicate note IDs, sends them to the SubMiner Anki proxy as private addNote metadata, and still returns note/duplicate data through the parser bridge. The proxy strips the private metadata before forwarding to upstream AnkiConnect, associates the duplicate IDs with the created note before auto-enrichment begins, and SubMiner also records the bridge result as a secondary cache path. Verified with bun test src/anki-integration/duplicate.test.ts src/anki-integration/card-creation.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/anki-connect-proxy.test.ts src/core/services/tokenizer/yomitan-parser-runtime.test.ts and bun run typecheck.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: TASK-263.2
|
||||
title: >-
|
||||
Keep Yomitan popup responsive during background add and pause/close before
|
||||
Kiku modal
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-01 00:42'
|
||||
updated_date: '2026-04-01 02:35'
|
||||
labels:
|
||||
- anki
|
||||
- yomitan
|
||||
- kiku
|
||||
- ux
|
||||
dependencies: []
|
||||
parent_task_id: TASK-263
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Make Yomitan popup add run in background without blocking popup responsiveness. Before opening Kiku field-grouping modal, pause MPV and close the Yomitan popup/parser window if open.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Definition of Done
|
||||
<!-- DOD:BEGIN -->
|
||||
- [x] #1 Popup save path returns immediately and prevents duplicate submits
|
||||
- [x] #2 Field-grouping modal request pauses MPV and closes Yomitan popup window first
|
||||
- [x] #3 Regression tests cover async save dispatch and main-side pause/close hook
|
||||
<!-- DOD:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
2026-03-31: Removed the custom pending label/gray save-button presentation from vendored Yomitan. Background add still runs asynchronously with the internal pending-save guard, so duplicate clicks are ignored while the button keeps its stock appearance.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Yomitan popup save dispatches note creation/add in the background with an internal pending-save guard so repeated clicks are ignored without blocking the popup. Before opening the Kiku field-grouping modal, the renderer now closes the visible lookup popup and pauses MPV. Follow-up UX polish removed the custom pending label/gray styling so the save button keeps Yomitan’s stock presentation while the background action runs.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: TASK-264
|
||||
title: Replace axios with native fetch across the project
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-04-01 00:44'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Remove axios from the codebase and migrate all project HTTP requests to the platform fetch API, preserving existing request behavior and error handling where applicable.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 No production code paths import or depend on axios.
|
||||
- [ ] #2 All existing HTTP requests use fetch or a project-local abstraction built on fetch.
|
||||
- [ ] #3 Request behavior remains functionally equivalent for headers, query params, bodies, status handling, and abort/error cases that are currently supported.
|
||||
- [ ] #4 Tests are updated or added to cover the migrated request flows.
|
||||
- [ ] #5 Documentation is updated if any request semantics or setup steps change.
|
||||
- [ ] #6 axios is removed from project dependencies if it is no longer needed.
|
||||
<!-- AC:END -->
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
id: TASK-265
|
||||
title: Add remote backend for immersion tracking and stats (prefer Postgres)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-04-01 00:47'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker-service.ts
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/storage.ts
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/sqlite.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/stats-daemon-runner.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/core/services/stats-server.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/main/boot/services.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/package.json
|
||||
documentation:
|
||||
- /home/sudacode/projects/japanese/SubMiner/docs/architecture/README.md
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/docs/architecture/stats-trends-data-flow.md
|
||||
- /home/sudacode/projects/japanese/SubMiner/README.md
|
||||
- /home/sudacode/projects/japanese/SubMiner/config.example.jsonc
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Enable immersion tracking/stats to use a remote authoritative backend so multiple devices can share the same history.
|
||||
|
||||
Current state: `ImmersionTrackerService` opens a local `immersion.sqlite` file from the app data/config path, `stats-daemon-runner` points at that same local file, and `config.example.jsonc` only exposes `immersionTracking.dbPath` for a local path override. The stats API/dashboard reads from the same tracker service and assumes the local database is the source of truth.
|
||||
|
||||
Goal: add a remote backend option that avoids shared filesystem/database-file syncing between devices. Do not use SSH/rsync/shared network filesystem as the primary sync strategy for live multi-device use.
|
||||
|
||||
Backend choice: prefer Postgres if it can be integrated without a broad new dependency surface or destabilizing the current runtime; otherwise use the least invasive remote backend that can be shipped with the current stack and document the tradeoff clearly. Preserve the current local SQLite mode as the default/offline fallback if possible.
|
||||
|
||||
This ticket should cover the full product/architecture change: configuration, storage access, stats reads, startup/error handling, migration/bootstrap from existing local data, tests, and docs.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 The app can be configured to use a remote authoritative backend for immersion tracking instead of only a local `immersion.sqlite` file.
|
||||
- [ ] #2 The chosen backend persists tracker writes and serves the existing stats read models across app restarts.
|
||||
- [ ] #3 Two devices can point at the same remote backend without relying on a shared filesystem or raw SQLite file sync.
|
||||
- [ ] #4 Local SQLite remains supported as the default or fallback mode for offline use.
|
||||
- [ ] #5 If the remote backend is unavailable or misconfigured, startup/write paths fail with actionable errors instead of silent data loss.
|
||||
- [ ] #6 A migration or bootstrap path exists to move existing local immersion data into the remote backend or seed a new device from it.
|
||||
- [ ] #7 Config/examples/docs explain the backend choice, required connection/setup details, and any security/network assumptions.
|
||||
- [ ] #8 Tests cover backend selection plus at least one representative write/read path against the remote backend.
|
||||
<!-- AC:END -->
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: TASK-266
|
||||
title: Preserve paused state for configured subtitle-jump keybindings
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-01 03:19'
|
||||
updated_date: '2026-04-01 03:19'
|
||||
labels:
|
||||
- renderer
|
||||
- mpv
|
||||
- keybindings
|
||||
- regression
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Regression: configured overlay keybindings that forward raw mpv subtitle-jump commands (for example previous-subtitle on H) can resume playback when invoked while paused. Keyboard-driven edge jumps already preserve paused state; configured keybindings should match that behavior.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Configured subtitle-jump keybindings preserve paused playback state after backward seek
|
||||
- [x] #2 Existing keyboard-driven subtitle navigation behavior remains unchanged
|
||||
- [x] #3 Regression test covers paused configured subtitle-jump keybinding handling
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Configured overlay keybindings that forward `sub-seek` commands now re-check paused state and reapply pause after the seek when playback was already paused. This aligns raw configured subtitle-jump keybindings with the existing keyboard-driven edge-jump behavior and adds regression coverage for the paused backward-seek case.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: TASK-267
|
||||
title: Port validated Yomitan popup changes to fork and resync submodule
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-01 03:30'
|
||||
updated_date: '2026-04-01 03:33'
|
||||
labels:
|
||||
- yomitan
|
||||
- submodule
|
||||
- git
|
||||
- integration
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Take the locally validated Yomitan popup/bridge changes from the vendored copy, apply them to the standalone `../subminer-yomitan` fork, verify the fork, push the fork commit, then reset the vendored working tree in SubMiner and update the submodule pointer to the pushed fork commit.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Standalone `../subminer-yomitan` contains the validated popup/bridge changes and passes the relevant regression test
|
||||
- [x] #2 The fork commit is pushed to its configured remote branch
|
||||
- [x] #3 SubMiner vendored Yomitan working tree is reset and the submodule pointer is updated to the pushed fork commit
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Applied the validated popup/bridge changes from the vendored Yomitan copy into `../subminer-yomitan`, added the focused async-save regression test there, installed fork deps, and verified with `npx vitest run test/display-anki-save.test.js`. Committed the fork changes as `feat: preserve async popup save state and duplicate metadata`, rebased onto the updated remote `main`, and pushed commit `69620abc` to `origin/main`. Then reset the vendored submodule working tree in SubMiner, checked it out at `69620abc`, and left the superproject with the submodule pointer updated from `3c9ee577` to `69620abc`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: TASK-268
|
||||
title: 'Address CodeRabbit review action items for PR #38'
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-01 05:35'
|
||||
updated_date: '2026-04-01 06:07'
|
||||
labels:
|
||||
- pr-review
|
||||
- coderabbit
|
||||
dependencies: []
|
||||
references:
|
||||
- 'https://github.com/ksyasuda/SubMiner/pull/38'
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Review unresolved CodeRabbit feedback on PR #38 and implement the actionable fixes without regressing duplicate grouping or popup behavior.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 All unresolved actionable CodeRabbit review comments on PR #38 are triaged and either fixed in code or explicitly identified as non-actionable or ambiguous.
|
||||
- [x] #2 Code changes preserve duplicate grouping and popup flow behavior covered by existing or added regression tests.
|
||||
- [x] #3 Relevant local verification for the affected areas passes.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
2026-04-01: Reopened for follow-up CodeRabbit round after commit 233bde58. Remaining actionable items: guard maxMatches <= 0 in duplicate exact-match helper and strengthen the duplicate tracking test fixture to prove deduplication as well as sorting.
|
||||
|
||||
2026-04-01: Follow-up round addressed locally. Added guard for maxMatches <= 0 in duplicate exact-match scanning and strengthened the pre-add duplicate tracking test fixture to prove deduplication as well as sorting.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Addressed all unresolved actionable CodeRabbit comments on PR #38. Fixed duplicate tracking so empty duplicate lists are not persisted after sentence-card creation, sanitized Yomitan add-note noteId values to accept only positive integers, preserved paused playback for configured subtitle-seek keybindings when pause state is unknown, and short-circuited duplicate exact-match scanning for single-result lookups. Added regression tests for each case and verified with `bun test` on the affected suites plus `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, and `bun run test:smoke:dist`.
|
||||
|
||||
Follow-up CodeRabbit round addressed locally: `findExactDuplicateNoteIds()` now returns early when `maxMatches <= 0`, and the sentence-card duplicate tracking regression test now uses a repeated duplicate ID to assert deduplication plus sorting. Re-verified with targeted duplicate/card tests, `bun run typecheck`, and `bun run test:fast`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
id: TASK-269
|
||||
title: 'Assess and address PR #39 latest CodeRabbit review round'
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-04-01 07:22'
|
||||
updated_date: '2026-04-01 07:55'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
- src/main
|
||||
- docs/architecture/README.md
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Assess unresolved CodeRabbit review threads on PR #39, fix valid findings in the current branch, and document any non-actionable findings so the review state is clear for follow-up.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 All unresolved CodeRabbit review threads on PR #39 are assessed and classified as fix or non-actionable with rationale
|
||||
- [x] #2 Valid findings are addressed in code without regressing current runtime behavior
|
||||
- [x] #3 Regression tests or targeted coverage are added when the reviewed behavior can be exercised locally
|
||||
- [x] #4 Relevant verification commands are run and results recorded in the task summary
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Confirm each unresolved CodeRabbit thread against current branch state and mark already-addressed items as non-actionable without code churn.
|
||||
2. Fix validated runtime issues in focused patches: AniList setup window stale close handler, headless known-word refresh effective config usage, main startup websocket probe wiring, startup warmup delegation, mpv media-path duplicate side effects, overlay visibility readiness guard, Discord presence service cleanup, and stats daemon self-stop handling.
|
||||
3. Tighten headless startup runtime typing so custom bootstrap deps require an explicit factory instead of unsafe casting.
|
||||
4. Add or extend targeted tests for behaviors that can be exercised locally, especially overlay visibility, Discord lifecycle cleanup, stats self-stop, and headless startup typing/runtime coverage.
|
||||
5. Run targeted tests first, then the relevant broader verification lane, and capture which CodeRabbit items were fixed versus assessed as already addressed.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Reassessed current branch after reset concern: most runtime/test fixes were still present; only live worktree delta was IPC playlist-browser cleanup plus the untracked backlog task record.
|
||||
|
||||
Validated and fixed CodeRabbit findings for stale AniList setup window clearing, effective headless Anki config usage, CLI websocket probe wiring, duplicate mpv media-path side effects, overlay visibility readiness, Discord presence service cleanup, stats self-stop, unsafe headless startup custom deps typing, and playlist-browser IPC wiring.
|
||||
|
||||
Assessed main-startup-runtime-bootstrap warmup comment as non-actionable at this layer: the guarded startBackgroundWarmupsIfAllowed wrapper exists in main-startup-bootstrap, while this lower bootstrap still needs to expose the raw mpv warmup command.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Assessed the unresolved CodeRabbit round on PR #39 and applied the confirmed fixes across the main-process runtime split. Updated AniList setup window teardown to avoid clearing newer windows, made headless known-word refresh use one effective Anki config end-to-end, wired the real mpv websocket probe into CLI startup, removed duplicate mpv media-path side effects, guarded overlay visibility changes behind overlay readiness, stopped Discord presence services before disable/replace, and prevented stats daemon stop from SIGTERMing the current Electron process. Also tightened headless startup typing so custom bootstrap deps require an explicit factory, normalized stats coordinator note-id typing, and completed playlist-browser IPC wiring/types so typecheck stays green.
|
||||
|
||||
Added targeted regression coverage for overlay visibility initialization, Discord lifecycle cleanup, stats self-stop, and headless startup custom-deps typing expectations. Verification run from the current tree: `bun run typecheck`, targeted `bun test` for overlay/discord/stats/headless startup files, and full `bun run test:fast` all passed.
|
||||
|
||||
Assessment outcome for remaining review noise: the playlist-browser open action was already wired in IPC bootstrap, and the warmup-delegation comment on `main-startup-runtime-bootstrap.ts` was not applied because the guarded wrapper lives one layer higher in `main-startup-bootstrap.ts`; this lower bootstrap still needs to surface the raw mpv warmup command consumed by that wrapper.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
6
changes/260-main-runtime-refactor.md
Normal file
6
changes/260-main-runtime-refactor.md
Normal file
@@ -0,0 +1,6 @@
|
||||
type: internal
|
||||
area: main
|
||||
|
||||
- Split `src/main.ts` into domain runtime wrappers and startup sequencing helpers.
|
||||
- Removed the last direct `src/main/runtime/*-main-deps.ts` imports from `src/main.ts`.
|
||||
- Kept startup behavior and IPC contracts stable while reducing composition-root size.
|
||||
6
changes/267-yomitan-kiku-popup.md
Normal file
6
changes/267-yomitan-kiku-popup.md
Normal file
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed Kiku duplicate grouping to reuse duplicate note IDs from both generic sentence-card creation and Yomitan popup mining instead of running extra duplicate scans after add.
|
||||
- Fixed the Yomitan popup mining flow to add cards in the background while keeping the stock popup progress feedback, then pause playback and close the lookup popup before the Kiku merge modal opens.
|
||||
- Fixed configured subtitle-jump keybindings so backward and forward subtitle seeks keep playback paused when invoked from a paused state.
|
||||
@@ -969,6 +969,7 @@ To refresh roughly once per day, set:
|
||||
| `disabled` | No field grouping; duplicate cards are left as-is |
|
||||
|
||||
`deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate.
|
||||
When the manual merge popup opens, SubMiner pauses playback and closes any open Yomitan popup first so the merge flow can take focus.
|
||||
|
||||
<video controls playsinline preload="metadata" poster="/assets/kiku-integration-poster.jpg" style="width: 100%; max-width: 960px;">
|
||||
<source :src="'/assets/kiku-integration.webm'" type="video/webm" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Architecture Map
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-26
|
||||
Last verified: 2026-03-31
|
||||
Owner: Kyle Yasuda
|
||||
Read when: runtime ownership, composition boundaries, or layering questions
|
||||
|
||||
@@ -24,6 +24,27 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
|
||||
## Current Shape
|
||||
|
||||
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
||||
- `src/main/*.ts` wrapper runtimes sit between `src/main.ts` and `src/main/runtime/**`
|
||||
so the composition root stays thin while exported `createBuild*MainDepsHandler`
|
||||
APIs remain internal plumbing. Key domain runtimes:
|
||||
- `anilist-runtime` – AniList token management, media tracking, retry queue
|
||||
- `cli-startup-runtime` – CLI command dispatch and initial-args handling
|
||||
- `discord-presence-lifecycle-runtime` – Discord Rich Presence lifecycle
|
||||
- `first-run-runtime` – first-run setup wizard
|
||||
- `ipc-runtime` – IPC handler registration and composition
|
||||
- `jellyfin-runtime` – Jellyfin session, playback, mpv orchestration
|
||||
- `main-startup-runtime` – top-level startup orchestration (app-ready → CLI → headless)
|
||||
- `main-startup-bootstrap` – wiring helper that builds startup runtime inputs
|
||||
- `mining-runtime` – Anki card mining actions
|
||||
- `mpv-runtime` – mpv client lifecycle
|
||||
- `overlay-ui-runtime` – overlay window management, visibility, tray
|
||||
- `overlay-geometry-runtime` – overlay bounds resolution
|
||||
- `shortcuts-runtime` – global shortcut registration
|
||||
- `startup-sequence-runtime` – headless known-word refresh and deferred startup sequencing
|
||||
- `stats-runtime` – immersion tracker, stats server, stats CLI
|
||||
- `subtitle-runtime` – subtitle prefetch, tokenization, caching
|
||||
- `youtube-runtime` – YouTube playback flow
|
||||
- `yomitan-runtime` – Yomitan extension loading and settings
|
||||
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
|
||||
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
|
||||
@@ -51,9 +51,16 @@ function ensureSubmodulePresent() {
|
||||
}
|
||||
|
||||
function getSourceState() {
|
||||
try {
|
||||
const revision = readCommand('git', ['rev-parse', 'HEAD'], submoduleDir);
|
||||
const dirty = readCommand('git', ['status', '--short', '--untracked-files=no'], submoduleDir);
|
||||
return { revision, dirty };
|
||||
} catch (error) {
|
||||
if (process.env.SUBMINER_YOMITAN_ALLOW_MISSING_GIT === '1') {
|
||||
return { revision: 'unknown', dirty: '' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isBuildCurrent(force) {
|
||||
|
||||
@@ -51,6 +51,7 @@ import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
||||
import { PollingRunner } from './anki-integration/polling';
|
||||
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
||||
import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki-integration/duplicate';
|
||||
import { findDuplicateNoteIds as findDuplicateNoteIdsForAnkiIntegration } from './anki-integration/duplicate';
|
||||
import { CardCreationService } from './anki-integration/card-creation';
|
||||
import { FieldGroupingService } from './anki-integration/field-grouping';
|
||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||
@@ -148,6 +149,7 @@ export class AnkiIntegration {
|
||||
private aiConfig: AiConfig;
|
||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||
private noteIdRedirects = new Map<number, number>();
|
||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||
|
||||
constructor(
|
||||
config: AnkiConnectConfig,
|
||||
@@ -264,6 +266,9 @@ export class AnkiIntegration {
|
||||
recordCardsAdded: (count, noteIds) => {
|
||||
this.recordCardsMinedSafely(count, noteIds, 'proxy');
|
||||
},
|
||||
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
this.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
|
||||
},
|
||||
getDeck: () => this.config.deck,
|
||||
findNotes: async (query, options) =>
|
||||
(await this.client.findNotes(query, options)) as number[],
|
||||
@@ -361,6 +366,10 @@ export class AnkiIntegration {
|
||||
trackLastAddedNoteId: (noteId) => {
|
||||
this.previousNoteIds.add(noteId);
|
||||
},
|
||||
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
||||
},
|
||||
findDuplicateNoteIds: (expression, noteInfo) => this.findDuplicateNoteIds(expression, noteInfo),
|
||||
recordCardsMinedCallback: (count, noteIds) => {
|
||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||
},
|
||||
@@ -382,6 +391,10 @@ export class AnkiIntegration {
|
||||
extractFields: (fields) => this.extractFields(fields),
|
||||
findDuplicateNote: (expression, noteId, noteInfo) =>
|
||||
this.findDuplicateNote(expression, noteId, noteInfo),
|
||||
getTrackedDuplicateNoteIds: (noteId) =>
|
||||
this.trackedDuplicateNoteIds.has(noteId)
|
||||
? [...(this.trackedDuplicateNoteIds.get(noteId) ?? [])]
|
||||
: null,
|
||||
hasAllConfiguredFields: (noteInfo, configuredFieldNames) =>
|
||||
this.hasAllConfiguredFields(noteInfo, configuredFieldNames),
|
||||
processNewCard: (noteId, options) => this.processNewCard(noteId, options),
|
||||
@@ -1042,6 +1055,10 @@ export class AnkiIntegration {
|
||||
);
|
||||
}
|
||||
|
||||
trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void {
|
||||
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
||||
}
|
||||
|
||||
private async findDuplicateNote(
|
||||
expression: string,
|
||||
excludeNoteId: number,
|
||||
@@ -1065,6 +1082,28 @@ export class AnkiIntegration {
|
||||
});
|
||||
}
|
||||
|
||||
private async findDuplicateNoteIds(
|
||||
expression: string,
|
||||
noteInfo: NoteInfo,
|
||||
): Promise<number[]> {
|
||||
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, {
|
||||
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||
getDeck: () => this.config.deck,
|
||||
getWordFieldCandidates: () => this.getConfiguredWordFieldCandidates(),
|
||||
resolveFieldName: (info, preferredName) => this.resolveNoteFieldName(info, preferredName),
|
||||
logInfo: (message) => {
|
||||
log.info(message);
|
||||
},
|
||||
logDebug: (message) => {
|
||||
log.debug(message);
|
||||
},
|
||||
logWarn: (message, error) => {
|
||||
log.warn(message, (error as Error).message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getPreferredSentenceAudioFieldName(): string {
|
||||
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
|
||||
return sentenceCardConfig.audioField || 'SentenceAudio';
|
||||
|
||||
@@ -324,6 +324,123 @@ test('proxy fallback-enqueues latest note for addNote responses without note IDs
|
||||
assert.deepEqual(recordedCards, [1]);
|
||||
});
|
||||
|
||||
test('proxy tracks duplicate note ids from addNote request metadata before enrichment', async () => {
|
||||
const processed: number[] = [];
|
||||
const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (noteId) => {
|
||||
processed.push(noteId);
|
||||
},
|
||||
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
tracked.push({ noteId, duplicateNoteIds });
|
||||
},
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
logError: () => undefined,
|
||||
});
|
||||
|
||||
(
|
||||
proxy as unknown as {
|
||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||
}
|
||||
).maybeEnqueueFromRequest(
|
||||
{
|
||||
action: 'addNote',
|
||||
params: {
|
||||
note: {},
|
||||
subminerDuplicateNoteIds: [11, -1, 40, 11, 25],
|
||||
},
|
||||
},
|
||||
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
|
||||
);
|
||||
|
||||
await waitForCondition(() => processed.length === 1);
|
||||
assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [11, 25, 40] }]);
|
||||
assert.deepEqual(processed, [42]);
|
||||
});
|
||||
|
||||
test('proxy strips SubMiner duplicate metadata before forwarding upstream addNote request', async () => {
|
||||
let upstreamBody = '';
|
||||
const upstream = http.createServer(async (req, res) => {
|
||||
upstreamBody = await new Promise<string>((resolve) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
res.statusCode = 200;
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ result: 42, error: null }));
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
const upstreamAddress = upstream.address();
|
||||
assert.ok(upstreamAddress && typeof upstreamAddress === 'object');
|
||||
const upstreamPort = upstreamAddress.port;
|
||||
|
||||
const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async () => undefined,
|
||||
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
tracked.push({ noteId, duplicateNoteIds });
|
||||
},
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
logError: () => undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
proxy.start({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
upstreamUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
});
|
||||
|
||||
const proxyServer = (
|
||||
proxy as unknown as {
|
||||
server: http.Server | null;
|
||||
}
|
||||
).server;
|
||||
assert.ok(proxyServer);
|
||||
if (!proxyServer.listening) {
|
||||
await once(proxyServer, 'listening');
|
||||
}
|
||||
const proxyAddress = proxyServer.address();
|
||||
assert.ok(proxyAddress && typeof proxyAddress === 'object');
|
||||
const proxyPort = proxyAddress.port;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'addNote',
|
||||
version: 6,
|
||||
params: {
|
||||
note: {
|
||||
deckName: 'Mining',
|
||||
modelName: 'Sentence',
|
||||
fields: { Expression: '食べる' },
|
||||
},
|
||||
subminerDuplicateNoteIds: [18, 7],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), { result: 42, error: null });
|
||||
await waitForCondition(() => tracked.length === 1);
|
||||
assert.equal(upstreamBody.includes('subminerDuplicateNoteIds'), false);
|
||||
assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [7, 18] }]);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
upstream.close();
|
||||
await once(upstream, 'close');
|
||||
}
|
||||
});
|
||||
|
||||
test('proxy returns addNote response without waiting for background enrichment', async () => {
|
||||
const processed: number[] = [];
|
||||
let releaseProcessing: (() => void) | undefined;
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AnkiConnectProxyServerDeps {
|
||||
shouldAutoUpdateNewCards: () => boolean;
|
||||
processNewCard: (noteId: number) => Promise<void>;
|
||||
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
||||
trackAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
getDeck?: () => string | undefined;
|
||||
findNotes?: (
|
||||
query: string,
|
||||
@@ -161,6 +162,7 @@ export class AnkiConnectProxyServer {
|
||||
}
|
||||
|
||||
try {
|
||||
const forwardedBody = req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
|
||||
const targetUrl = new URL(req.url || '/', upstreamUrl).toString();
|
||||
const contentType =
|
||||
typeof req.headers['content-type'] === 'string'
|
||||
@@ -169,7 +171,7 @@ export class AnkiConnectProxyServer {
|
||||
const upstreamResponse = await this.client.request<ArrayBuffer>({
|
||||
url: targetUrl,
|
||||
method: req.method,
|
||||
data: req.method === 'POST' ? rawBody : undefined,
|
||||
data: req.method === 'POST' ? forwardedBody : undefined,
|
||||
headers: {
|
||||
'content-type': contentType,
|
||||
},
|
||||
@@ -219,6 +221,8 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||
|
||||
const noteIds =
|
||||
action === 'multi'
|
||||
? this.collectMultiResultIds(requestJson, responseResult)
|
||||
@@ -231,6 +235,77 @@ export class AnkiConnectProxyServer {
|
||||
this.enqueueNotes(noteIds);
|
||||
}
|
||||
|
||||
private maybeTrackDuplicateNoteIds(
|
||||
requestJson: Record<string, unknown>,
|
||||
action: string,
|
||||
responseResult: unknown,
|
||||
): void {
|
||||
if (action !== 'addNote') {
|
||||
return;
|
||||
}
|
||||
const duplicateNoteIds = this.getRequestDuplicateNoteIds(requestJson);
|
||||
if (duplicateNoteIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const noteId = this.collectSingleResultId(responseResult)[0];
|
||||
if (!noteId) {
|
||||
return;
|
||||
}
|
||||
this.deps.trackAddedDuplicateNoteIds?.(noteId, duplicateNoteIds);
|
||||
}
|
||||
|
||||
private getForwardRequestBody(
|
||||
rawBody: Buffer,
|
||||
requestJson: Record<string, unknown> | null,
|
||||
): Buffer {
|
||||
if (!requestJson) {
|
||||
return rawBody;
|
||||
}
|
||||
|
||||
const sanitized = this.sanitizeRequestJson(requestJson);
|
||||
if (sanitized === requestJson) {
|
||||
return rawBody;
|
||||
}
|
||||
|
||||
return Buffer.from(JSON.stringify(sanitized), 'utf8');
|
||||
}
|
||||
|
||||
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
|
||||
const action =
|
||||
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
|
||||
if (action !== 'addNote') {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
const params =
|
||||
requestJson.params && typeof requestJson.params === 'object'
|
||||
? (requestJson.params as Record<string, unknown>)
|
||||
: null;
|
||||
if (!params || !Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds')) {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
const nextParams = { ...params };
|
||||
delete nextParams.subminerDuplicateNoteIds;
|
||||
return {
|
||||
...requestJson,
|
||||
params: nextParams,
|
||||
};
|
||||
}
|
||||
|
||||
private getRequestDuplicateNoteIds(requestJson: Record<string, unknown>): number[] {
|
||||
const params =
|
||||
requestJson.params && typeof requestJson.params === 'object'
|
||||
? (requestJson.params as Record<string, unknown>)
|
||||
: null;
|
||||
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
|
||||
? params.subminerDuplicateNoteIds
|
||||
: [];
|
||||
return [...new Set(rawNoteIds.filter((entry): entry is number => {
|
||||
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
|
||||
}))].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {
|
||||
if (action === 'addNote' || action === 'addNotes') {
|
||||
return true;
|
||||
|
||||
@@ -397,3 +397,178 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
||||
});
|
||||
|
||||
test('CardCreationService tracks pre-add duplicate note ids for kiku sentence cards', async () => {
|
||||
const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||
const duplicateLookupExpressions: string[] = [];
|
||||
|
||||
const service = new CardCreationService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
audio: 'SentenceAudio',
|
||||
},
|
||||
media: {
|
||||
generateAudio: false,
|
||||
generateImage: false,
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 1,
|
||||
currentSubEnd: 2,
|
||||
currentTimePos: 1.5,
|
||||
currentAudioStreamIndex: 0,
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async () => 42,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [],
|
||||
updateNoteFields: async () => undefined,
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: () => null,
|
||||
resolveNoteFieldName: () => null,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'manual',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
findDuplicateNoteIds: async (expression) => {
|
||||
duplicateLookupExpressions.push(expression);
|
||||
return [18, 7, 30, 7];
|
||||
},
|
||||
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
trackedDuplicates.push({ noteId, duplicateNoteIds });
|
||||
},
|
||||
});
|
||||
|
||||
const created = await service.createSentenceCard('重複文', 0, 1);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(duplicateLookupExpressions, ['重複文']);
|
||||
assert.deepEqual(trackedDuplicates, [{ noteId: 42, duplicateNoteIds: [7, 18, 30] }]);
|
||||
});
|
||||
|
||||
test('CardCreationService does not track duplicate ids when pre-add lookup returns none', async () => {
|
||||
const trackedDuplicates: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||
|
||||
const service = new CardCreationService({
|
||||
getConfig: () =>
|
||||
({
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
sentence: 'Sentence',
|
||||
audio: 'SentenceAudio',
|
||||
},
|
||||
media: {
|
||||
generateAudio: false,
|
||||
generateImage: false,
|
||||
},
|
||||
behavior: {},
|
||||
ai: false,
|
||||
}) as AnkiConnectConfig,
|
||||
getAiConfig: () => ({}),
|
||||
getTimingTracker: () => ({}) as never,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
currentVideoPath: '/video.mp4',
|
||||
currentSubText: '字幕',
|
||||
currentSubStart: 1,
|
||||
currentSubEnd: 2,
|
||||
currentTimePos: 1.5,
|
||||
currentAudioStreamIndex: 0,
|
||||
}) as never,
|
||||
client: {
|
||||
addNote: async () => 42,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [],
|
||||
updateNoteFields: async () => undefined,
|
||||
storeMediaFile: async () => undefined,
|
||||
findNotes: async () => [],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
mediaGenerator: {
|
||||
generateAudio: async () => null,
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
},
|
||||
showOsdNotification: () => undefined,
|
||||
showUpdateResult: () => undefined,
|
||||
showStatusNotification: () => undefined,
|
||||
showNotification: async () => undefined,
|
||||
beginUpdateProgress: () => undefined,
|
||||
endUpdateProgress: () => undefined,
|
||||
withUpdateProgress: async (_message, action) => action(),
|
||||
resolveConfiguredFieldName: () => null,
|
||||
resolveNoteFieldName: () => null,
|
||||
getAnimatedImageLeadInSeconds: async () => 0,
|
||||
extractFields: () => ({}),
|
||||
processSentence: (sentence) => sentence,
|
||||
setCardTypeFields: () => undefined,
|
||||
mergeFieldValue: (_existing, newValue) => newValue,
|
||||
formatMiscInfoPattern: () => '',
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
model: 'Sentence',
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'manual',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
trackLastAddedNoteId: () => undefined,
|
||||
findDuplicateNoteIds: async () => [],
|
||||
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
trackedDuplicates.push({ noteId, duplicateNoteIds });
|
||||
},
|
||||
});
|
||||
|
||||
const created = await service.createSentenceCard('重複なし', 0, 1);
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.deepEqual(trackedDuplicates, []);
|
||||
});
|
||||
|
||||
@@ -112,6 +112,11 @@ interface CardCreationDeps {
|
||||
isUpdateInProgress: () => boolean;
|
||||
setUpdateInProgress: (value: boolean) => void;
|
||||
trackLastAddedNoteId?: (noteId: number) => void;
|
||||
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
findDuplicateNoteIds?: (
|
||||
expression: string,
|
||||
noteInfo: CardCreationNoteInfo,
|
||||
) => Promise<number[]>;
|
||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||
}
|
||||
|
||||
@@ -548,6 +553,33 @@ export class CardCreationService {
|
||||
fields[getConfiguredWordFieldName(this.deps.getConfig())] = sentence;
|
||||
}
|
||||
|
||||
const pendingNoteInfo = this.createPendingNoteInfo(fields);
|
||||
const pendingNoteFields = Object.fromEntries(
|
||||
Object.entries(fields).map(([name, value]) => [name.toLowerCase(), value]),
|
||||
);
|
||||
const pendingExpressionText = getPreferredWordValueFromExtractedFields(
|
||||
pendingNoteFields,
|
||||
this.deps.getConfig(),
|
||||
).trim();
|
||||
let duplicateNoteIds: number[] = [];
|
||||
if (
|
||||
sentenceCardConfig.kikuEnabled &&
|
||||
sentenceCardConfig.kikuFieldGrouping !== 'disabled' &&
|
||||
pendingExpressionText &&
|
||||
this.deps.findDuplicateNoteIds
|
||||
) {
|
||||
try {
|
||||
duplicateNoteIds = sortUniqueNoteIds(
|
||||
await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo),
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
'Failed to capture pre-add duplicate note ids:',
|
||||
(error as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const deck = this.deps.getConfig().deck || 'Default';
|
||||
let noteId: number;
|
||||
try {
|
||||
@@ -570,6 +602,14 @@ export class CardCreationService {
|
||||
log.warn('Failed to track last added note:', (error as Error).message);
|
||||
}
|
||||
|
||||
if (duplicateNoteIds.length > 0) {
|
||||
try {
|
||||
this.deps.trackLastAddedDuplicateNoteIds?.(noteId, duplicateNoteIds);
|
||||
} catch (error) {
|
||||
log.warn('Failed to track duplicate note ids:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.deps.recordCardsMinedCallback?.(1, [noteId]);
|
||||
} catch (error) {
|
||||
@@ -685,6 +725,15 @@ export class CardCreationService {
|
||||
);
|
||||
}
|
||||
|
||||
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
|
||||
return {
|
||||
noteId: -1,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(fields).map(([name, value]) => [name, { value }]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async mediaGenerateAudio(
|
||||
videoPath: string,
|
||||
startTime: number,
|
||||
@@ -764,3 +813,7 @@ export class CardCreationService {
|
||||
return `image_${timestamp}.${ext}`;
|
||||
}
|
||||
}
|
||||
|
||||
function sortUniqueNoteIds(noteIds: number[]): number[] {
|
||||
return [...new Set(noteIds)].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { findDuplicateNote, type NoteInfo } from './duplicate';
|
||||
import { findDuplicateNote, findDuplicateNoteIds, type NoteInfo } from './duplicate';
|
||||
|
||||
function createFieldResolver(noteInfo: NoteInfo, preferredName: string): string | null {
|
||||
const names = Object.keys(noteInfo.fields);
|
||||
@@ -267,3 +267,62 @@ test('findDuplicateNote does not disable retries on findNotes calls', async () =
|
||||
assert.ok(seenOptions.length > 0);
|
||||
assert.ok(seenOptions.every((options) => options?.maxRetries !== 0));
|
||||
});
|
||||
|
||||
test('findDuplicateNote stops after the first exact-match chunk', async () => {
|
||||
const currentNote: NoteInfo = {
|
||||
noteId: 100,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
};
|
||||
|
||||
let notesInfoCalls = 0;
|
||||
const candidateIds = Array.from({ length: 51 }, (_, index) => 200 + index);
|
||||
const duplicateId = await findDuplicateNote('貴様', 100, currentNote, {
|
||||
findNotes: async () => candidateIds,
|
||||
notesInfo: async (noteIds) => {
|
||||
notesInfoCalls += 1;
|
||||
return noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
Expression: { value: noteId === 200 ? '貴様' : `別単語-${noteId}` },
|
||||
},
|
||||
}));
|
||||
},
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
assert.equal(duplicateId, 200);
|
||||
assert.equal(notesInfoCalls, 1);
|
||||
});
|
||||
|
||||
test('findDuplicateNoteIds returns no matches when maxMatches is zero', async () => {
|
||||
const currentNote: NoteInfo = {
|
||||
noteId: 100,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
};
|
||||
|
||||
let notesInfoCalls = 0;
|
||||
const duplicateIds = await findDuplicateNoteIds('貴様', 100, currentNote, {
|
||||
findNotes: async () => [200],
|
||||
notesInfo: async (noteIds) => {
|
||||
notesInfoCalls += 1;
|
||||
return noteIds.map((noteId) => ({
|
||||
noteId,
|
||||
fields: {
|
||||
Expression: { value: '貴様' },
|
||||
},
|
||||
}));
|
||||
},
|
||||
getDeck: () => 'Japanese::Mining',
|
||||
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
|
||||
logWarn: () => {},
|
||||
}, 0);
|
||||
|
||||
assert.deepEqual(duplicateIds, []);
|
||||
assert.equal(notesInfoCalls, 0);
|
||||
});
|
||||
|
||||
@@ -24,13 +24,30 @@ export async function findDuplicateNote(
|
||||
noteInfo: NoteInfo,
|
||||
deps: DuplicateDetectionDeps,
|
||||
): Promise<number | null> {
|
||||
const duplicateNoteIds = await findDuplicateNoteIds(
|
||||
expression,
|
||||
excludeNoteId,
|
||||
noteInfo,
|
||||
deps,
|
||||
1,
|
||||
);
|
||||
return duplicateNoteIds[0] ?? null;
|
||||
}
|
||||
|
||||
export async function findDuplicateNoteIds(
|
||||
expression: string,
|
||||
excludeNoteId: number,
|
||||
noteInfo: NoteInfo,
|
||||
deps: DuplicateDetectionDeps,
|
||||
maxMatches?: number,
|
||||
): Promise<number[]> {
|
||||
const configuredWordFieldCandidates = deps.getWordFieldCandidates?.() ?? ['Expression', 'Word'];
|
||||
const sourceCandidates = getDuplicateSourceCandidates(
|
||||
noteInfo,
|
||||
expression,
|
||||
configuredWordFieldCandidates,
|
||||
);
|
||||
if (sourceCandidates.length === 0) return null;
|
||||
if (sourceCandidates.length === 0) return [];
|
||||
deps.logInfo?.(
|
||||
`[duplicate] start expr="${expression}" sourceCandidates=${sourceCandidates
|
||||
.map((entry) => `${entry.fieldName}:${entry.value}`)
|
||||
@@ -83,42 +100,49 @@ export async function findDuplicateNote(
|
||||
}
|
||||
}
|
||||
|
||||
return await findFirstExactDuplicateNoteId(
|
||||
return await findExactDuplicateNoteIds(
|
||||
noteIds,
|
||||
excludeNoteId,
|
||||
sourceCandidates.map((candidate) => candidate.value),
|
||||
configuredWordFieldCandidates,
|
||||
deps,
|
||||
maxMatches,
|
||||
);
|
||||
} catch (error) {
|
||||
deps.logWarn('Duplicate search failed:', error);
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function findFirstExactDuplicateNoteId(
|
||||
function findExactDuplicateNoteIds(
|
||||
candidateNoteIds: Iterable<number>,
|
||||
excludeNoteId: number,
|
||||
sourceValues: string[],
|
||||
candidateFieldNames: string[],
|
||||
deps: DuplicateDetectionDeps,
|
||||
): Promise<number | null> {
|
||||
maxMatches?: number,
|
||||
): Promise<number[]> {
|
||||
if (maxMatches !== undefined && maxMatches <= 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const candidates = Array.from(candidateNoteIds).filter((id) => id !== excludeNoteId);
|
||||
deps.logDebug?.(`[duplicate] candidateIds=${candidates.length} exclude=${excludeNoteId}`);
|
||||
if (candidates.length === 0) {
|
||||
deps.logInfo?.('[duplicate] no candidates after query + exclude');
|
||||
return Promise.resolve(null);
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const normalizedValues = new Set(
|
||||
sourceValues.map((value) => normalizeDuplicateValue(value)).filter((value) => value.length > 0),
|
||||
);
|
||||
if (normalizedValues.size === 0) {
|
||||
return Promise.resolve(null);
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const chunkSize = 50;
|
||||
return (async () => {
|
||||
const matches: number[] = [];
|
||||
for (let i = 0; i < candidates.length; i += chunkSize) {
|
||||
const chunk = candidates.slice(i, i + chunkSize);
|
||||
const notesInfoResult = (await deps.notesInfo(chunk)) as unknown[];
|
||||
@@ -133,13 +157,19 @@ function findFirstExactDuplicateNoteId(
|
||||
`[duplicate] exact-match noteId=${noteInfo.noteId} field=${resolvedField}`,
|
||||
);
|
||||
deps.logInfo?.(`[duplicate] matched noteId=${noteInfo.noteId} field=${resolvedField}`);
|
||||
return noteInfo.noteId;
|
||||
matches.push(noteInfo.noteId);
|
||||
if (maxMatches !== undefined && matches.length >= maxMatches) {
|
||||
return matches;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
deps.logInfo?.('[duplicate] no exact match in candidate notes');
|
||||
return null;
|
||||
}
|
||||
return matches;
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ function createHarness(
|
||||
noteIds?: number[];
|
||||
notesInfo?: NoteInfo[][];
|
||||
duplicateNoteId?: number | null;
|
||||
trackedDuplicateNoteIds?: number[] | null;
|
||||
hasAllConfiguredFields?: boolean;
|
||||
manualHandled?: boolean;
|
||||
expression?: string | null;
|
||||
@@ -74,6 +75,7 @@ function createHarness(
|
||||
duplicateRequests.push({ expression, excludeNoteId });
|
||||
return options.duplicateNoteId ?? 99;
|
||||
},
|
||||
getTrackedDuplicateNoteIds: () => options.trackedDuplicateNoteIds ?? null,
|
||||
hasAllConfiguredFields: () => options.hasAllConfiguredFields ?? true,
|
||||
processNewCard: async (noteId, processOptions) => {
|
||||
processCalls.push({ noteId, options: processOptions });
|
||||
@@ -223,6 +225,46 @@ test('triggerFieldGroupingForLastAddedCard finds the newest note and hands off t
|
||||
]);
|
||||
});
|
||||
|
||||
test('triggerFieldGroupingForLastAddedCard prefers tracked duplicate note ids before duplicate lookup', async () => {
|
||||
const harness = createHarness({
|
||||
noteIds: [7],
|
||||
notesInfo: [
|
||||
[
|
||||
{
|
||||
noteId: 7,
|
||||
fields: {
|
||||
Expression: { value: 'word-7' },
|
||||
Sentence: { value: 'line-7' },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
noteId: 7,
|
||||
fields: {
|
||||
Expression: { value: 'word-7' },
|
||||
Sentence: { value: 'line-7' },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
trackedDuplicateNoteIds: [12, 40, 25],
|
||||
duplicateNoteId: 99,
|
||||
hasAllConfiguredFields: true,
|
||||
});
|
||||
|
||||
await harness.service.triggerFieldGroupingForLastAddedCard();
|
||||
|
||||
assert.deepEqual(harness.duplicateRequests, []);
|
||||
assert.deepEqual(harness.autoCalls, [
|
||||
{
|
||||
originalNoteId: 40,
|
||||
newNoteId: 7,
|
||||
expression: 'word-7',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fields are missing', async () => {
|
||||
const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = [];
|
||||
const harness = createHarness({
|
||||
|
||||
@@ -41,6 +41,7 @@ interface FieldGroupingDeps {
|
||||
excludeNoteId: number,
|
||||
noteInfo: FieldGroupingNoteInfo,
|
||||
) => Promise<number | null>;
|
||||
getTrackedDuplicateNoteIds?: (noteId: number) => number[] | null;
|
||||
hasAllConfiguredFields: (
|
||||
noteInfo: FieldGroupingNoteInfo,
|
||||
configuredFieldNames: (string | undefined)[],
|
||||
@@ -117,11 +118,11 @@ export class FieldGroupingService {
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicateNoteId = await this.deps.findDuplicateNote(
|
||||
expressionText,
|
||||
noteId,
|
||||
noteInfoBeforeUpdate,
|
||||
);
|
||||
const trackedDuplicateNoteIds = this.deps.getTrackedDuplicateNoteIds?.(noteId) ?? null;
|
||||
const duplicateNoteId =
|
||||
trackedDuplicateNoteIds !== null
|
||||
? pickMostRecentDuplicateNoteId(trackedDuplicateNoteIds, noteId)
|
||||
: await this.deps.findDuplicateNote(expressionText, noteId, noteInfoBeforeUpdate);
|
||||
if (duplicateNoteId === null) {
|
||||
this.deps.showOsdNotification('No duplicate card found');
|
||||
return;
|
||||
@@ -243,3 +244,17 @@ export class FieldGroupingService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pickMostRecentDuplicateNoteId(
|
||||
duplicateNoteIds: number[],
|
||||
excludeNoteId: number,
|
||||
): number | null {
|
||||
let bestNoteId: number | null = null;
|
||||
for (const noteId of duplicateNoteIds) {
|
||||
if (noteId === excludeNoteId) continue;
|
||||
if (bestNoteId === null || noteId > bestNoteId) {
|
||||
bestNoteId = noteId;
|
||||
}
|
||||
}
|
||||
return bestNoteId;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as path from 'path';
|
||||
import test from 'node:test';
|
||||
import * as vm from 'node:vm';
|
||||
import {
|
||||
addYomitanNoteViaSearch,
|
||||
getYomitanDictionaryInfo,
|
||||
importYomitanDictionaryFromZip,
|
||||
deleteYomitanDictionaryByTitle,
|
||||
@@ -1373,3 +1374,48 @@ test('deleteYomitanDictionaryByTitle uses settings automation bridge instead of
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('addYomitanNoteViaSearch returns note and duplicate ids from the bridge payload', async () => {
|
||||
const deps = createDeps(async (_script) => ({
|
||||
noteId: 42,
|
||||
duplicateNoteIds: [18, 7, 18],
|
||||
}));
|
||||
|
||||
const result = await addYomitanNoteViaSearch('食べる', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
noteId: 42,
|
||||
duplicateNoteIds: [18, 7, 18],
|
||||
});
|
||||
});
|
||||
|
||||
test('addYomitanNoteViaSearch rejects invalid numeric note ids from the bridge shortcut', async () => {
|
||||
const deps = createDeps(async () => NaN);
|
||||
|
||||
const result = await addYomitanNoteViaSearch('食べる', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
noteId: null,
|
||||
duplicateNoteIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('addYomitanNoteViaSearch sanitizes invalid payload note ids while keeping valid duplicate ids', async () => {
|
||||
const deps = createDeps(async (_script) => ({
|
||||
noteId: -1,
|
||||
duplicateNoteIds: [18, 0, 7.5, 7],
|
||||
}));
|
||||
|
||||
const result = await addYomitanNoteViaSearch('食べる', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
noteId: null,
|
||||
duplicateNoteIds: [18, 7],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,11 @@ interface YomitanProfileMetadata {
|
||||
dictionaryFrequencyModeByName: Partial<Record<string, YomitanFrequencyMode>>;
|
||||
}
|
||||
|
||||
export interface YomitanAddNoteResult {
|
||||
noteId: number | null;
|
||||
duplicateNoteIds: number[];
|
||||
}
|
||||
|
||||
const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
|
||||
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
|
||||
const yomitanFrequencyCacheByWindow = new WeakMap<
|
||||
@@ -1984,11 +1989,11 @@ export async function addYomitanNoteViaSearch(
|
||||
word: string,
|
||||
deps: YomitanParserRuntimeDeps,
|
||||
logger: LoggerLike,
|
||||
): Promise<number | null> {
|
||||
): Promise<YomitanAddNoteResult> {
|
||||
const isReady = await ensureYomitanParserWindow(deps, logger);
|
||||
const parserWindow = deps.getYomitanParserWindow();
|
||||
if (!isReady || !parserWindow || parserWindow.isDestroyed()) {
|
||||
return null;
|
||||
return { noteId: null, duplicateNoteIds: [] };
|
||||
}
|
||||
|
||||
const escapedWord = JSON.stringify(word);
|
||||
@@ -2003,10 +2008,35 @@ export async function addYomitanNoteViaSearch(
|
||||
`;
|
||||
|
||||
try {
|
||||
const noteId = await parserWindow.webContents.executeJavaScript(script, true);
|
||||
return typeof noteId === 'number' ? noteId : null;
|
||||
const result = await parserWindow.webContents.executeJavaScript(script, true);
|
||||
if (typeof result === 'number') {
|
||||
return {
|
||||
noteId: Number.isInteger(result) && result > 0 ? result : null,
|
||||
duplicateNoteIds: [],
|
||||
};
|
||||
}
|
||||
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
||||
const envelope = result as {
|
||||
noteId?: unknown;
|
||||
duplicateNoteIds?: unknown;
|
||||
};
|
||||
return {
|
||||
noteId:
|
||||
typeof envelope.noteId === 'number' &&
|
||||
Number.isInteger(envelope.noteId) &&
|
||||
envelope.noteId > 0
|
||||
? envelope.noteId
|
||||
: null,
|
||||
duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds)
|
||||
? envelope.duplicateNoteIds.filter(
|
||||
(entry): entry is number => typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
return { noteId: null, duplicateNoteIds: [] };
|
||||
} catch (err) {
|
||||
logger.error('Yomitan addNoteFromWord failed:', (err as Error).message);
|
||||
return null;
|
||||
return { noteId: null, duplicateNoteIds: [] };
|
||||
}
|
||||
}
|
||||
|
||||
4818
src/main.ts
4818
src/main.ts
File diff suppressed because it is too large
Load Diff
110
src/main/anilist-runtime-coordinator.ts
Normal file
110
src/main/anilist-runtime-coordinator.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, BrowserWindow, shell } from 'electron';
|
||||
|
||||
import { DEFAULT_MIN_WATCH_RATIO } from '../shared/watch-threshold';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import {
|
||||
guessAnilistMediaInfo,
|
||||
updateAnilistPostWatchProgress,
|
||||
} from '../core/services/anilist/anilist-updater';
|
||||
import type { AnilistSetupWindowLike } from './anilist-runtime';
|
||||
import { createAnilistRuntime } from './anilist-runtime';
|
||||
import {
|
||||
isAllowedAnilistExternalUrl,
|
||||
isAllowedAnilistSetupNavigationUrl,
|
||||
} from './anilist-url-guard';
|
||||
|
||||
export interface AnilistRuntimeCoordinatorInput {
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
isTrackingEnabled: (config: ResolvedConfig) => boolean;
|
||||
tokenStore: Parameters<typeof createAnilistRuntime>[0]['tokenStore'];
|
||||
updateQueue: Parameters<typeof createAnilistRuntime>[0]['updateQueue'];
|
||||
appState: {
|
||||
currentMediaPath: string | null;
|
||||
currentMediaTitle: string | null;
|
||||
mpvClient: {
|
||||
currentTimePos?: number | null;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
anilistSetupWindow: BrowserWindow | null;
|
||||
};
|
||||
dictionarySupport: {
|
||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
||||
};
|
||||
actions: {
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
};
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, details?: unknown) => void;
|
||||
error: (message: string, error?: unknown) => void;
|
||||
debug: (message: string, details?: unknown) => void;
|
||||
};
|
||||
constants: {
|
||||
authorizeUrl: string;
|
||||
clientId: string;
|
||||
responseType: string;
|
||||
redirectUri: string;
|
||||
developerSettingsUrl: string;
|
||||
durationRetryIntervalMs: number;
|
||||
minWatchSeconds: number;
|
||||
maxAttemptedUpdateKeys: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function createAnilistRuntimeCoordinator(input: AnilistRuntimeCoordinatorInput) {
|
||||
return createAnilistRuntime<ResolvedConfig, AnilistSetupWindowLike>({
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
isTrackingEnabled: (config) => input.isTrackingEnabled(config),
|
||||
tokenStore: input.tokenStore,
|
||||
updateQueue: input.updateQueue,
|
||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
||||
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
|
||||
getWatchedSeconds: () => input.appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||
hasMpvClient: () => Boolean(input.appState.mpvClient),
|
||||
requestMpvDuration: async () => input.appState.mpvClient?.requestProperty('duration'),
|
||||
resolveMediaPathForJimaku: (currentMediaPath) =>
|
||||
input.dictionarySupport.resolveMediaPathForJimaku(currentMediaPath),
|
||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) => guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||
updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
createBrowserWindow: (options) => {
|
||||
const window = new BrowserWindow(options);
|
||||
input.appState.anilistSetupWindow = window;
|
||||
window.on('closed', () => {
|
||||
input.appState.anilistSetupWindow = null;
|
||||
});
|
||||
return window as unknown as AnilistSetupWindowLike;
|
||||
},
|
||||
authorizeUrl: input.constants.authorizeUrl,
|
||||
clientId: input.constants.clientId,
|
||||
responseType: input.constants.responseType,
|
||||
redirectUri: input.constants.redirectUri,
|
||||
developerSettingsUrl: input.constants.developerSettingsUrl,
|
||||
isAllowedExternalUrl: (url) => isAllowedAnilistExternalUrl(url),
|
||||
isAllowedNavigationUrl: (url) => isAllowedAnilistSetupNavigationUrl(url),
|
||||
openExternal: (url) => shell.openExternal(url),
|
||||
showMpvOsd: (message) => input.actions.showMpvOsd(message),
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.actions.showDesktopNotification(title, options),
|
||||
logInfo: (message) => input.logger.info(message),
|
||||
logWarn: (message, details) => input.logger.warn(message, details),
|
||||
logError: (message, error) => input.logger.error(message, error),
|
||||
logDebug: (message, details) => input.logger.debug(message, details),
|
||||
isDefaultApp: () => Boolean(process.defaultApp),
|
||||
getArgv: () => process.argv,
|
||||
execPath: process.execPath,
|
||||
resolvePath: (value) => path.resolve(value),
|
||||
setAsDefaultProtocolClient: (scheme, appPath, args) =>
|
||||
appPath
|
||||
? app.setAsDefaultProtocolClient(scheme, appPath, args)
|
||||
: app.setAsDefaultProtocolClient(scheme),
|
||||
now: () => Date.now(),
|
||||
durationRetryIntervalMs: input.constants.durationRetryIntervalMs,
|
||||
minWatchSeconds: input.constants.minWatchSeconds,
|
||||
minWatchRatio: DEFAULT_MIN_WATCH_RATIO,
|
||||
maxAttemptedUpdateKeys: input.constants.maxAttemptedUpdateKeys,
|
||||
});
|
||||
}
|
||||
192
src/main/anilist-runtime.test.ts
Normal file
192
src/main/anilist-runtime.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createAnilistRuntime } from './anilist-runtime';
|
||||
|
||||
function createSetupWindow() {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
let destroyed = false;
|
||||
return {
|
||||
window: {
|
||||
focus: () => {},
|
||||
close: () => {
|
||||
destroyed = true;
|
||||
for (const handler of handlers.get('closed') ?? []) {
|
||||
handler();
|
||||
}
|
||||
},
|
||||
isDestroyed: () => destroyed,
|
||||
on: (event: 'closed', handler: () => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
||||
},
|
||||
loadURL: async () => {},
|
||||
webContents: {
|
||||
setWindowOpenHandler: () => ({ action: 'deny' as const }),
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
||||
},
|
||||
getURL: () => 'about:blank',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntime(overrides: Partial<Parameters<typeof createAnilistRuntime>[0]> = {}) {
|
||||
const savedTokens: string[] = [];
|
||||
const queueCalls: string[] = [];
|
||||
const notifications: string[] = [];
|
||||
const state = {
|
||||
config: {
|
||||
anilist: {
|
||||
enabled: true,
|
||||
accessToken: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
const setup = createSetupWindow();
|
||||
|
||||
const runtime = createAnilistRuntime({
|
||||
getResolvedConfig: () => state.config,
|
||||
isTrackingEnabled: (config) => config.anilist.enabled === true,
|
||||
tokenStore: {
|
||||
saveToken: (token) => {
|
||||
savedTokens.push(token);
|
||||
},
|
||||
loadToken: () => null,
|
||||
clearToken: () => {
|
||||
savedTokens.push('cleared');
|
||||
},
|
||||
},
|
||||
updateQueue: {
|
||||
enqueue: (key, title, episode) => {
|
||||
queueCalls.push(`enqueue:${key}:${title}:${episode}`);
|
||||
},
|
||||
nextReady: () => ({
|
||||
key: 'retry-1',
|
||||
title: 'Demo',
|
||||
episode: 2,
|
||||
createdAt: 1,
|
||||
attemptCount: 0,
|
||||
nextAttemptAt: 0,
|
||||
lastError: null,
|
||||
}),
|
||||
markSuccess: (key) => {
|
||||
queueCalls.push(`success:${key}`);
|
||||
},
|
||||
markFailure: (key, message) => {
|
||||
queueCalls.push(`failure:${key}:${message}`);
|
||||
},
|
||||
getSnapshot: () => ({
|
||||
pending: 3,
|
||||
ready: 1,
|
||||
deadLetter: 2,
|
||||
}),
|
||||
},
|
||||
getCurrentMediaPath: () => '/tmp/demo.mkv',
|
||||
getCurrentMediaTitle: () => 'Demo',
|
||||
getWatchedSeconds: () => 0,
|
||||
hasMpvClient: () => false,
|
||||
requestMpvDuration: async () => 120,
|
||||
resolveMediaPathForJimaku: (value) => value,
|
||||
guessAnilistMediaInfo: async () => null,
|
||||
updateAnilistPostWatchProgress: async () => ({
|
||||
status: 'updated',
|
||||
message: 'updated ok',
|
||||
}),
|
||||
createBrowserWindow: () => setup.window,
|
||||
authorizeUrl: 'https://anilist.co/api/v2/oauth/authorize',
|
||||
clientId: '36084',
|
||||
responseType: 'token',
|
||||
redirectUri: 'https://anilist.subminer.moe/',
|
||||
developerSettingsUrl: 'https://anilist.co/settings/developer',
|
||||
isAllowedExternalUrl: () => true,
|
||||
isAllowedNavigationUrl: () => true,
|
||||
openExternal: async () => {},
|
||||
showMpvOsd: (message) => {
|
||||
notifications.push(`osd:${message}`);
|
||||
},
|
||||
showDesktopNotification: (_title, options) => {
|
||||
notifications.push(`notify:${options.body}`);
|
||||
},
|
||||
logInfo: (message) => {
|
||||
notifications.push(`info:${message}`);
|
||||
},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
logDebug: () => {},
|
||||
isDefaultApp: () => false,
|
||||
getArgv: () => [],
|
||||
execPath: process.execPath,
|
||||
resolvePath: (value) => value,
|
||||
setAsDefaultProtocolClient: () => true,
|
||||
now: () => 1234,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return {
|
||||
runtime,
|
||||
state,
|
||||
savedTokens,
|
||||
queueCalls,
|
||||
notifications,
|
||||
};
|
||||
}
|
||||
|
||||
test('anilist runtime saves setup token and updates resolved state', () => {
|
||||
const harness = createRuntime();
|
||||
|
||||
const consumed = harness.runtime.consumeAnilistSetupTokenFromUrl(
|
||||
'subminer://anilist-setup?access_token=token-123',
|
||||
);
|
||||
|
||||
assert.equal(consumed, true);
|
||||
assert.deepEqual(harness.savedTokens, ['token-123']);
|
||||
assert.equal(harness.runtime.getStatusSnapshot().tokenStatus, 'resolved');
|
||||
assert.equal(harness.runtime.getStatusSnapshot().tokenSource, 'stored');
|
||||
assert.equal(harness.runtime.getStatusSnapshot().tokenMessage, 'saved token from AniList login');
|
||||
assert.ok(harness.notifications.includes('notify:AniList login success'));
|
||||
});
|
||||
|
||||
test('anilist runtime bypasses refresh when tracking disabled', async () => {
|
||||
const harness = createRuntime();
|
||||
harness.state.config = {
|
||||
anilist: {
|
||||
enabled: false,
|
||||
accessToken: '',
|
||||
},
|
||||
};
|
||||
|
||||
const token = await harness.runtime.refreshAnilistClientSecretStateIfEnabled({
|
||||
force: true,
|
||||
});
|
||||
|
||||
assert.equal(token, null);
|
||||
assert.equal(harness.runtime.getStatusSnapshot().tokenStatus, 'not_checked');
|
||||
assert.equal(harness.runtime.getStatusSnapshot().tokenSource, 'none');
|
||||
});
|
||||
|
||||
test('anilist runtime refreshes queue snapshot and retry state after processing', async () => {
|
||||
const harness = createRuntime({
|
||||
tokenStore: {
|
||||
saveToken: () => {},
|
||||
loadToken: () => 'stored-token',
|
||||
clearToken: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
harness.runtime.refreshRetryQueueState();
|
||||
assert.deepEqual(harness.runtime.getQueueStatusSnapshot(), {
|
||||
pending: 3,
|
||||
ready: 1,
|
||||
deadLetter: 2,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const result = await harness.runtime.processNextAnilistRetryUpdate();
|
||||
|
||||
assert.deepEqual(result, { ok: true, message: 'updated ok' });
|
||||
assert.ok(harness.queueCalls.includes('success:retry-1'));
|
||||
assert.equal(harness.runtime.getQueueStatusSnapshot().lastAttemptAt, 1234);
|
||||
assert.equal(harness.runtime.getQueueStatusSnapshot().lastError, null);
|
||||
});
|
||||
495
src/main/anilist-runtime.ts
Normal file
495
src/main/anilist-runtime.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import {
|
||||
createInitialAnilistMediaGuessRuntimeState,
|
||||
createInitialAnilistRetryQueueState,
|
||||
createInitialAnilistSecretResolutionState,
|
||||
createInitialAnilistUpdateInFlightState,
|
||||
type AnilistMediaGuessRuntimeState,
|
||||
type AnilistRetryQueueState,
|
||||
type AnilistSecretResolutionState,
|
||||
} from './state';
|
||||
import { createAnilistStateRuntime } from './runtime/anilist-state';
|
||||
import { composeAnilistSetupHandlers } from './runtime/composers/anilist-setup-composer';
|
||||
import { composeAnilistTrackingHandlers } from './runtime/composers/anilist-tracking-composer';
|
||||
import {
|
||||
buildAnilistSetupUrl,
|
||||
consumeAnilistSetupCallbackUrl,
|
||||
loadAnilistManualTokenEntry,
|
||||
openAnilistSetupInBrowser,
|
||||
} from './runtime/anilist-setup';
|
||||
import {
|
||||
createMaybeFocusExistingAnilistSetupWindowHandler,
|
||||
createOpenAnilistSetupWindowHandler,
|
||||
} from './runtime/anilist-setup-window';
|
||||
import {
|
||||
buildAnilistAttemptKey,
|
||||
rememberAnilistAttemptedUpdateKey,
|
||||
} from './runtime/anilist-post-watch';
|
||||
import { createCreateAnilistSetupWindowHandler } from './runtime/setup-window-factory';
|
||||
import type {
|
||||
AnilistMediaGuess,
|
||||
AnilistPostWatchUpdateResult,
|
||||
} from '../core/services/anilist/anilist-updater';
|
||||
import type { AnilistUpdateQueue } from '../core/services/anilist/anilist-update-queue';
|
||||
|
||||
export interface AnilistSetupWindowLike {
|
||||
focus: () => void;
|
||||
close: () => void;
|
||||
isDestroyed: () => boolean;
|
||||
on: (event: 'closed', handler: () => void) => void;
|
||||
loadURL: (url: string) => Promise<void> | void;
|
||||
webContents: {
|
||||
setWindowOpenHandler: (handler: (details: { url: string }) => { action: 'deny' }) => void;
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => void;
|
||||
getURL: () => string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnilistTokenStoreLike {
|
||||
saveToken: (token: string) => void;
|
||||
loadToken: () => string | null | undefined;
|
||||
clearToken: () => void;
|
||||
}
|
||||
|
||||
export interface AnilistRuntimeInput<
|
||||
TConfig extends { anilist: { accessToken: string; enabled?: boolean } } = {
|
||||
anilist: { accessToken: string; enabled?: boolean };
|
||||
},
|
||||
TWindow extends AnilistSetupWindowLike = AnilistSetupWindowLike,
|
||||
> {
|
||||
getResolvedConfig: () => TConfig;
|
||||
isTrackingEnabled: (config: TConfig) => boolean;
|
||||
tokenStore: AnilistTokenStoreLike;
|
||||
updateQueue: AnilistUpdateQueue;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
getWatchedSeconds: () => number;
|
||||
hasMpvClient: () => boolean;
|
||||
requestMpvDuration: () => Promise<unknown>;
|
||||
resolveMediaPathForJimaku: (currentMediaPath: string | null) => string | null;
|
||||
guessAnilistMediaInfo: (
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
) => Promise<AnilistMediaGuess | null>;
|
||||
updateAnilistPostWatchProgress: (
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
) => Promise<AnilistPostWatchUpdateResult>;
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
||||
authorizeUrl: string;
|
||||
clientId: string;
|
||||
responseType: string;
|
||||
redirectUri: string;
|
||||
developerSettingsUrl: string;
|
||||
isAllowedExternalUrl: (url: string) => boolean;
|
||||
isAllowedNavigationUrl: (url: string) => boolean;
|
||||
openExternal: (url: string) => Promise<unknown> | void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
logDebug: (message: string, details?: unknown) => void;
|
||||
isDefaultApp: () => boolean;
|
||||
getArgv: () => string[];
|
||||
execPath: string;
|
||||
resolvePath: (value: string) => string;
|
||||
setAsDefaultProtocolClient: (scheme: string, path?: string, args?: string[]) => boolean;
|
||||
now?: () => number;
|
||||
durationRetryIntervalMs?: number;
|
||||
minWatchSeconds?: number;
|
||||
minWatchRatio?: number;
|
||||
maxAttemptedUpdateKeys?: number;
|
||||
}
|
||||
|
||||
export interface AnilistRuntime {
|
||||
notifyAnilistSetup: (message: string) => void;
|
||||
consumeAnilistSetupTokenFromUrl: (rawUrl: string) => boolean;
|
||||
handleAnilistSetupProtocolUrl: (rawUrl: string) => boolean;
|
||||
registerSubminerProtocolClient: () => void;
|
||||
openAnilistSetupWindow: () => void;
|
||||
refreshAnilistClientSecretState: (options?: {
|
||||
force?: boolean;
|
||||
allowSetupPrompt?: boolean;
|
||||
}) => Promise<string | null>;
|
||||
refreshAnilistClientSecretStateIfEnabled: (options?: {
|
||||
force?: boolean;
|
||||
allowSetupPrompt?: boolean;
|
||||
}) => Promise<string | null>;
|
||||
getCurrentAnilistMediaKey: () => string | null;
|
||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||
getAnilistMediaGuessRuntimeState: () => AnilistMediaGuessRuntimeState;
|
||||
setAnilistMediaGuessRuntimeState: (state: AnilistMediaGuessRuntimeState) => void;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => Promise<number | null>;
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => Promise<AnilistMediaGuess | null>;
|
||||
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
setClientSecretState: (partial: Partial<AnilistSecretResolutionState>) => void;
|
||||
refreshRetryQueueState: () => void;
|
||||
getStatusSnapshot: () => {
|
||||
tokenStatus: AnilistSecretResolutionState['status'];
|
||||
tokenSource: AnilistSecretResolutionState['source'];
|
||||
tokenMessage: string | null;
|
||||
tokenResolvedAt: number | null;
|
||||
tokenErrorAt: number | null;
|
||||
queuePending: number;
|
||||
queueReady: number;
|
||||
queueDeadLetter: number;
|
||||
queueLastAttemptAt: number | null;
|
||||
queueLastError: string | null;
|
||||
};
|
||||
getQueueStatusSnapshot: () => AnilistRetryQueueState;
|
||||
clearTokenState: () => void;
|
||||
getSetupWindow: () => AnilistSetupWindowLike | null;
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION_RETRY_INTERVAL_MS = 15_000;
|
||||
const DEFAULT_MIN_WATCH_SECONDS = 10 * 60;
|
||||
const DEFAULT_MIN_WATCH_RATIO = 0.85;
|
||||
const DEFAULT_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
|
||||
|
||||
export function createAnilistRuntime<
|
||||
TConfig extends { anilist: { accessToken: string; enabled?: boolean } },
|
||||
TWindow extends AnilistSetupWindowLike,
|
||||
>(input: AnilistRuntimeInput<TConfig, TWindow>): AnilistRuntime {
|
||||
const now = input.now ?? Date.now;
|
||||
|
||||
let setupWindow: TWindow | null = null;
|
||||
let setupPageOpened = false;
|
||||
let cachedAccessToken: string | null = null;
|
||||
let clientSecretState = createInitialAnilistSecretResolutionState();
|
||||
let retryQueueState = createInitialAnilistRetryQueueState();
|
||||
let mediaGuessRuntimeState = createInitialAnilistMediaGuessRuntimeState();
|
||||
let updateInFlightState = createInitialAnilistUpdateInFlightState();
|
||||
const attemptedUpdateKeys = new Set<string>();
|
||||
|
||||
const stateRuntime = createAnilistStateRuntime({
|
||||
getClientSecretState: () => clientSecretState,
|
||||
setClientSecretState: (next) => {
|
||||
clientSecretState = next;
|
||||
},
|
||||
getRetryQueueState: () => retryQueueState,
|
||||
setRetryQueueState: (next) => {
|
||||
retryQueueState = next;
|
||||
},
|
||||
getUpdateQueueSnapshot: () => input.updateQueue.getSnapshot(),
|
||||
clearStoredToken: () => input.tokenStore.clearToken(),
|
||||
clearCachedAccessToken: () => {
|
||||
cachedAccessToken = null;
|
||||
},
|
||||
});
|
||||
|
||||
const rememberAttemptedUpdate = (key: string): void => {
|
||||
rememberAnilistAttemptedUpdateKey(
|
||||
attemptedUpdateKeys,
|
||||
key,
|
||||
input.maxAttemptedUpdateKeys ?? DEFAULT_MAX_ATTEMPTED_UPDATE_KEYS,
|
||||
);
|
||||
};
|
||||
|
||||
const maybeFocusExistingSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({
|
||||
getSetupWindow: () => setupWindow,
|
||||
});
|
||||
const createSetupWindow = createCreateAnilistSetupWindowHandler({
|
||||
createBrowserWindow: (options) => input.createBrowserWindow(options),
|
||||
});
|
||||
|
||||
const {
|
||||
notifyAnilistSetup,
|
||||
consumeAnilistSetupTokenFromUrl,
|
||||
handleAnilistSetupProtocolUrl,
|
||||
registerSubminerProtocolClient,
|
||||
} = composeAnilistSetupHandlers({
|
||||
notifyDeps: {
|
||||
hasMpvClient: () => input.hasMpvClient(),
|
||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
||||
showDesktopNotification: (title, options) => input.showDesktopNotification(title, options),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
},
|
||||
consumeTokenDeps: {
|
||||
consumeAnilistSetupCallbackUrl,
|
||||
saveToken: (token) => input.tokenStore.saveToken(token),
|
||||
setCachedToken: (token) => {
|
||||
cachedAccessToken = token;
|
||||
},
|
||||
setResolvedState: (resolvedAt) => {
|
||||
stateRuntime.setClientSecretState({
|
||||
status: 'resolved',
|
||||
source: 'stored',
|
||||
message: 'saved token from AniList login',
|
||||
resolvedAt,
|
||||
errorAt: null,
|
||||
});
|
||||
},
|
||||
setSetupPageOpened: (opened) => {
|
||||
setupPageOpened = opened;
|
||||
},
|
||||
onSuccess: () => {
|
||||
notifyAnilistSetup('AniList login success');
|
||||
},
|
||||
closeWindow: () => {
|
||||
if (setupWindow && !setupWindow.isDestroyed()) {
|
||||
setupWindow.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
handleProtocolDeps: {
|
||||
consumeAnilistSetupTokenFromUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl),
|
||||
logWarn: (message, details) => input.logWarn(message, details),
|
||||
},
|
||||
registerProtocolClientDeps: {
|
||||
isDefaultApp: () => input.isDefaultApp(),
|
||||
getArgv: () => input.getArgv(),
|
||||
execPath: input.execPath,
|
||||
resolvePath: (value) => input.resolvePath(value),
|
||||
setAsDefaultProtocolClient: (scheme, targetPath, args) =>
|
||||
input.setAsDefaultProtocolClient(scheme, targetPath, args),
|
||||
logDebug: (message, details) => input.logDebug(message, details),
|
||||
},
|
||||
});
|
||||
|
||||
const openAnilistSetupWindow = createOpenAnilistSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => maybeFocusExistingSetupWindow(),
|
||||
createSetupWindow: () => createSetupWindow(),
|
||||
buildAuthorizeUrl: () =>
|
||||
buildAnilistSetupUrl({
|
||||
authorizeUrl: input.authorizeUrl,
|
||||
clientId: input.clientId,
|
||||
responseType: input.responseType,
|
||||
redirectUri: input.redirectUri,
|
||||
}),
|
||||
consumeCallbackUrl: (rawUrl) => consumeAnilistSetupTokenFromUrl(rawUrl),
|
||||
openSetupInBrowser: (authorizeUrl) =>
|
||||
openAnilistSetupInBrowser({
|
||||
authorizeUrl,
|
||||
openExternal: async (url) => {
|
||||
await input.openExternal(url);
|
||||
},
|
||||
logError: (message, error) => input.logError(message, error),
|
||||
}),
|
||||
loadManualTokenEntry: (window, authorizeUrl) =>
|
||||
loadAnilistManualTokenEntry({
|
||||
setupWindow: window as never,
|
||||
authorizeUrl,
|
||||
developerSettingsUrl: input.developerSettingsUrl,
|
||||
logWarn: (message, details) => input.logWarn(message, details),
|
||||
}),
|
||||
redirectUri: input.redirectUri,
|
||||
developerSettingsUrl: input.developerSettingsUrl,
|
||||
isAllowedExternalUrl: (url) => input.isAllowedExternalUrl(url),
|
||||
isAllowedNavigationUrl: (url) => input.isAllowedNavigationUrl(url),
|
||||
logWarn: (message, details) => input.logWarn(message, details),
|
||||
logError: (message, details) => input.logError(message, details),
|
||||
clearSetupWindow: () => {
|
||||
setupWindow = null;
|
||||
},
|
||||
setSetupPageOpened: (opened) => {
|
||||
setupPageOpened = opened;
|
||||
},
|
||||
setSetupWindow: (window) => {
|
||||
setupWindow = window;
|
||||
},
|
||||
openExternal: (url) => {
|
||||
void input.openExternal(url);
|
||||
},
|
||||
});
|
||||
|
||||
const trackingRuntime = composeAnilistTrackingHandlers({
|
||||
refreshClientSecretMainDeps: {
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
isAnilistTrackingEnabled: (config) => input.isTrackingEnabled(config as TConfig),
|
||||
getCachedAccessToken: () => cachedAccessToken,
|
||||
setCachedAccessToken: (token) => {
|
||||
cachedAccessToken = token;
|
||||
},
|
||||
saveStoredToken: (token) => {
|
||||
input.tokenStore.saveToken(token);
|
||||
},
|
||||
loadStoredToken: () => input.tokenStore.loadToken(),
|
||||
setClientSecretState: (state) => {
|
||||
clientSecretState = state;
|
||||
},
|
||||
getAnilistSetupPageOpened: () => setupPageOpened,
|
||||
setAnilistSetupPageOpened: (opened) => {
|
||||
setupPageOpened = opened;
|
||||
},
|
||||
openAnilistSetupWindow: () => {
|
||||
openAnilistSetupWindow();
|
||||
},
|
||||
now,
|
||||
},
|
||||
getCurrentMediaKeyMainDeps: {
|
||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
||||
},
|
||||
resetMediaTrackingMainDeps: {
|
||||
setMediaKey: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaKey: value };
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaDurationSec: value };
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value };
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value };
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, lastDurationProbeAtMs: value };
|
||||
},
|
||||
},
|
||||
getMediaGuessRuntimeStateMainDeps: {
|
||||
getMediaKey: () => mediaGuessRuntimeState.mediaKey,
|
||||
getMediaDurationSec: () => mediaGuessRuntimeState.mediaDurationSec,
|
||||
getMediaGuess: () => mediaGuessRuntimeState.mediaGuess,
|
||||
getMediaGuessPromise: () => mediaGuessRuntimeState.mediaGuessPromise,
|
||||
getLastDurationProbeAtMs: () => mediaGuessRuntimeState.lastDurationProbeAtMs,
|
||||
},
|
||||
setMediaGuessRuntimeStateMainDeps: {
|
||||
setMediaKey: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaKey: value };
|
||||
},
|
||||
setMediaDurationSec: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaDurationSec: value };
|
||||
},
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value };
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value };
|
||||
},
|
||||
setLastDurationProbeAtMs: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, lastDurationProbeAtMs: value };
|
||||
},
|
||||
},
|
||||
resetMediaGuessStateMainDeps: {
|
||||
setMediaGuess: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuess: value };
|
||||
},
|
||||
setMediaGuessPromise: (value) => {
|
||||
mediaGuessRuntimeState = { ...mediaGuessRuntimeState, mediaGuessPromise: value };
|
||||
},
|
||||
},
|
||||
maybeProbeDurationMainDeps: {
|
||||
getState: () => mediaGuessRuntimeState,
|
||||
setState: (state) => {
|
||||
mediaGuessRuntimeState = state;
|
||||
},
|
||||
durationRetryIntervalMs: input.durationRetryIntervalMs ?? DEFAULT_DURATION_RETRY_INTERVAL_MS,
|
||||
now,
|
||||
requestMpvDuration: () => input.requestMpvDuration(),
|
||||
logWarn: (message, error) => input.logWarn(message, error),
|
||||
},
|
||||
ensureMediaGuessMainDeps: {
|
||||
getState: () => mediaGuessRuntimeState,
|
||||
setState: (state) => {
|
||||
mediaGuessRuntimeState = state;
|
||||
},
|
||||
resolveMediaPathForJimaku: (currentMediaPath) =>
|
||||
input.resolveMediaPathForJimaku(currentMediaPath),
|
||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
||||
input.guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||
},
|
||||
processNextRetryUpdateMainDeps: {
|
||||
nextReady: () => input.updateQueue.nextReady(),
|
||||
refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(),
|
||||
setLastAttemptAt: (value) => {
|
||||
retryQueueState = { ...retryQueueState, lastAttemptAt: value };
|
||||
},
|
||||
setLastError: (value) => {
|
||||
retryQueueState = { ...retryQueueState, lastError: value };
|
||||
},
|
||||
refreshAnilistClientSecretState: () => trackingRuntime.refreshAnilistClientSecretState(),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||
input.updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
markSuccess: (key) => {
|
||||
input.updateQueue.markSuccess(key);
|
||||
},
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
rememberAttemptedUpdate(key);
|
||||
},
|
||||
markFailure: (key, message) => {
|
||||
input.updateQueue.markFailure(key, message);
|
||||
},
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
now,
|
||||
},
|
||||
maybeRunPostWatchUpdateMainDeps: {
|
||||
getInFlight: () => updateInFlightState.inFlight,
|
||||
setInFlight: (value) => {
|
||||
updateInFlightState = { ...updateInFlightState, inFlight: value };
|
||||
},
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
isAnilistTrackingEnabled: (config) => input.isTrackingEnabled(config as TConfig),
|
||||
getCurrentMediaKey: () => trackingRuntime.getCurrentAnilistMediaKey(),
|
||||
hasMpvClient: () => input.hasMpvClient(),
|
||||
getTrackedMediaKey: () => mediaGuessRuntimeState.mediaKey,
|
||||
resetTrackedMedia: (mediaKey) => {
|
||||
trackingRuntime.resetAnilistMediaTracking(mediaKey);
|
||||
},
|
||||
getWatchedSeconds: () => input.getWatchedSeconds(),
|
||||
maybeProbeAnilistDuration: (mediaKey) => trackingRuntime.maybeProbeAnilistDuration(mediaKey),
|
||||
ensureAnilistMediaGuess: (mediaKey) => trackingRuntime.ensureAnilistMediaGuess(mediaKey),
|
||||
hasAttemptedUpdateKey: (key) => attemptedUpdateKeys.has(key),
|
||||
processNextAnilistRetryUpdate: () => trackingRuntime.processNextAnilistRetryUpdate(),
|
||||
refreshAnilistClientSecretState: () => trackingRuntime.refreshAnilistClientSecretState(),
|
||||
enqueueRetry: (key, title, episode) => {
|
||||
input.updateQueue.enqueue(key, title, episode);
|
||||
},
|
||||
markRetryFailure: (key, message) => {
|
||||
input.updateQueue.markFailure(key, message);
|
||||
},
|
||||
markRetrySuccess: (key) => {
|
||||
input.updateQueue.markSuccess(key);
|
||||
},
|
||||
refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||
input.updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
rememberAttemptedUpdate(key);
|
||||
},
|
||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logWarn: (message) => input.logWarn(message),
|
||||
minWatchSeconds: input.minWatchSeconds ?? DEFAULT_MIN_WATCH_SECONDS,
|
||||
minWatchRatio: input.minWatchRatio ?? DEFAULT_MIN_WATCH_RATIO,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
notifyAnilistSetup,
|
||||
consumeAnilistSetupTokenFromUrl,
|
||||
handleAnilistSetupProtocolUrl,
|
||||
registerSubminerProtocolClient,
|
||||
openAnilistSetupWindow,
|
||||
refreshAnilistClientSecretState: (options) =>
|
||||
trackingRuntime.refreshAnilistClientSecretState(options),
|
||||
refreshAnilistClientSecretStateIfEnabled: (options) => {
|
||||
if (!input.isTrackingEnabled(input.getResolvedConfig())) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return trackingRuntime.refreshAnilistClientSecretState(options);
|
||||
},
|
||||
getCurrentAnilistMediaKey: () => trackingRuntime.getCurrentAnilistMediaKey(),
|
||||
resetAnilistMediaTracking: (mediaKey) => trackingRuntime.resetAnilistMediaTracking(mediaKey),
|
||||
getAnilistMediaGuessRuntimeState: () => trackingRuntime.getAnilistMediaGuessRuntimeState(),
|
||||
setAnilistMediaGuessRuntimeState: (state) =>
|
||||
trackingRuntime.setAnilistMediaGuessRuntimeState(state),
|
||||
resetAnilistMediaGuessState: () => trackingRuntime.resetAnilistMediaGuessState(),
|
||||
maybeProbeAnilistDuration: (mediaKey) => trackingRuntime.maybeProbeAnilistDuration(mediaKey),
|
||||
ensureAnilistMediaGuess: (mediaKey) => trackingRuntime.ensureAnilistMediaGuess(mediaKey),
|
||||
processNextAnilistRetryUpdate: () => trackingRuntime.processNextAnilistRetryUpdate(),
|
||||
maybeRunAnilistPostWatchUpdate: () => trackingRuntime.maybeRunAnilistPostWatchUpdate(),
|
||||
setClientSecretState: (partial) => stateRuntime.setClientSecretState(partial),
|
||||
refreshRetryQueueState: () => stateRuntime.refreshRetryQueueState(),
|
||||
getStatusSnapshot: () => stateRuntime.getStatusSnapshot(),
|
||||
getQueueStatusSnapshot: () => stateRuntime.getQueueStatusSnapshot(),
|
||||
clearTokenState: () => stateRuntime.clearTokenState(),
|
||||
getSetupWindow: () => setupWindow,
|
||||
};
|
||||
}
|
||||
|
||||
export { buildAnilistAttemptKey };
|
||||
270
src/main/app-ready-runtime.test.ts
Normal file
270
src/main/app-ready-runtime.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createAppReadyRuntime } from './app-ready-runtime';
|
||||
|
||||
test('app ready runtime shares overlay startup prereqs with youtube runtime init path', async () => {
|
||||
let subtitlePosition: unknown | null = null;
|
||||
let keybindingsCount = 0;
|
||||
let hasMpvClient = false;
|
||||
let runtimeOptionsManager: unknown | null = null;
|
||||
let subtitleTimingTracker: unknown | null = null;
|
||||
let overlayRuntimeInitialized = false;
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createAppReadyRuntime({
|
||||
reload: {
|
||||
reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }),
|
||||
logInfo: () => {},
|
||||
logWarning: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
startConfigHotReload: () => {},
|
||||
refreshAnilistClientSecretState: async () => undefined,
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {},
|
||||
},
|
||||
},
|
||||
criticalConfig: {
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {
|
||||
throw new Error('quit');
|
||||
},
|
||||
},
|
||||
},
|
||||
immersion: {
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
immersionTracking: {
|
||||
enabled: true,
|
||||
batchSize: 1,
|
||||
flushIntervalMs: 1,
|
||||
queueCap: 1,
|
||||
payloadCapBytes: 1,
|
||||
maintenanceIntervalMs: 1,
|
||||
retention: {
|
||||
eventsDays: 1,
|
||||
telemetryDays: 1,
|
||||
sessionsDays: 1,
|
||||
dailyRollupsDays: 1,
|
||||
monthlyRollupsDays: 1,
|
||||
vacuumIntervalDays: 1,
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
|
||||
createTrackerService: () => ({}) as never,
|
||||
setTracker: () => {},
|
||||
getMpvClient: () => null,
|
||||
seedTrackerFromCurrentMedia: () => {},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
runner: {
|
||||
ensureDefaultConfigBootstrap: () => {},
|
||||
getSubtitlePosition: () => subtitlePosition,
|
||||
loadSubtitlePosition: () => {
|
||||
subtitlePosition = { mode: 'bottom' };
|
||||
calls.push('loadSubtitlePosition');
|
||||
},
|
||||
getKeybindingsCount: () => keybindingsCount,
|
||||
resolveKeybindings: () => {
|
||||
keybindingsCount = 3;
|
||||
calls.push('resolveKeybindings');
|
||||
},
|
||||
hasMpvClient: () => hasMpvClient,
|
||||
createMpvClient: () => {
|
||||
hasMpvClient = true;
|
||||
calls.push('createMpvClient');
|
||||
},
|
||||
getRuntimeOptionsManager: () => runtimeOptionsManager,
|
||||
initRuntimeOptionsManager: () => {
|
||||
runtimeOptionsManager = {};
|
||||
calls.push('initRuntimeOptionsManager');
|
||||
},
|
||||
getSubtitleTimingTracker: () => subtitleTimingTracker,
|
||||
createSubtitleTimingTracker: () => {
|
||||
subtitleTimingTracker = {};
|
||||
calls.push('createSubtitleTimingTracker');
|
||||
},
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
ankiConnect: {
|
||||
enabled: false,
|
||||
},
|
||||
}) as never,
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {},
|
||||
setSecondarySubMode: () => {},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 5174,
|
||||
defaultAnnotationWebsocketPort: 6678,
|
||||
defaultTexthookerPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
loadYomitanExtension: async () => {},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('ensureYomitanExtensionLoaded');
|
||||
},
|
||||
handleFirstRunSetup: async () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
initializeOverlayRuntime: () => {
|
||||
overlayRuntimeInitialized = true;
|
||||
calls.push('initializeOverlayRuntime');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
|
||||
},
|
||||
handleInitialArgs: () => {},
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
|
||||
});
|
||||
|
||||
runtime.ensureOverlayStartupPrereqs();
|
||||
runtime.ensureOverlayStartupPrereqs();
|
||||
await runtime.ensureYoutubePlaybackRuntimeReady();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'loadSubtitlePosition',
|
||||
'resolveKeybindings',
|
||||
'createMpvClient',
|
||||
'initRuntimeOptionsManager',
|
||||
'createSubtitleTimingTracker',
|
||||
'ensureYomitanExtensionLoaded',
|
||||
'initializeOverlayRuntime',
|
||||
]);
|
||||
});
|
||||
|
||||
test('app ready runtime reuses existing overlay runtime during youtube readiness', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createAppReadyRuntime({
|
||||
reload: {
|
||||
reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }),
|
||||
logInfo: () => {},
|
||||
logWarning: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
startConfigHotReload: () => {},
|
||||
refreshAnilistClientSecretState: async () => undefined,
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {},
|
||||
},
|
||||
},
|
||||
criticalConfig: {
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {
|
||||
throw new Error('quit');
|
||||
},
|
||||
},
|
||||
},
|
||||
immersion: {
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
immersionTracking: {
|
||||
enabled: true,
|
||||
batchSize: 1,
|
||||
flushIntervalMs: 1,
|
||||
queueCap: 1,
|
||||
payloadCapBytes: 1,
|
||||
maintenanceIntervalMs: 1,
|
||||
retention: {
|
||||
eventsDays: 1,
|
||||
telemetryDays: 1,
|
||||
sessionsDays: 1,
|
||||
dailyRollupsDays: 1,
|
||||
monthlyRollupsDays: 1,
|
||||
vacuumIntervalDays: 1,
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
|
||||
createTrackerService: () => ({}) as never,
|
||||
setTracker: () => {},
|
||||
getMpvClient: () => null,
|
||||
seedTrackerFromCurrentMedia: () => {},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
runner: {
|
||||
ensureDefaultConfigBootstrap: () => {},
|
||||
getSubtitlePosition: () => ({}) as never,
|
||||
loadSubtitlePosition: () => {
|
||||
throw new Error('should not load subtitle position');
|
||||
},
|
||||
getKeybindingsCount: () => 1,
|
||||
resolveKeybindings: () => {
|
||||
throw new Error('should not resolve keybindings');
|
||||
},
|
||||
hasMpvClient: () => true,
|
||||
createMpvClient: () => {
|
||||
throw new Error('should not create mpv client');
|
||||
},
|
||||
getRuntimeOptionsManager: () => ({}),
|
||||
initRuntimeOptionsManager: () => {
|
||||
throw new Error('should not init runtime options');
|
||||
},
|
||||
getSubtitleTimingTracker: () => ({}),
|
||||
createSubtitleTimingTracker: () => {
|
||||
throw new Error('should not create subtitle timing tracker');
|
||||
},
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {},
|
||||
setSecondarySubMode: () => {},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 5174,
|
||||
defaultAnnotationWebsocketPort: 6678,
|
||||
defaultTexthookerPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
loadYomitanExtension: async () => {},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('ensureYomitanExtensionLoaded');
|
||||
},
|
||||
handleFirstRunSetup: async () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('initializeOverlayRuntime');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
|
||||
},
|
||||
handleInitialArgs: () => {},
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
});
|
||||
|
||||
await runtime.ensureYoutubePlaybackRuntimeReady();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'ensureYomitanExtensionLoaded',
|
||||
'ensureOverlayWindowsReadyForVisibilityActions',
|
||||
]);
|
||||
});
|
||||
264
src/main/app-ready-runtime.ts
Normal file
264
src/main/app-ready-runtime.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { LogLevelSource } from '../logger';
|
||||
import type { ConfigValidationWarning, SecondarySubMode } from '../types';
|
||||
import { composeAppReadyRuntime } from './runtime/composers/app-ready-composer';
|
||||
|
||||
type AppReadyConfigLike = {
|
||||
logging?: {
|
||||
level?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type SubtitlePositionLike = unknown;
|
||||
type RuntimeOptionsManagerLike = unknown;
|
||||
type SubtitleTimingTrackerLike = unknown;
|
||||
type ImmersionTrackingConfigLike = {
|
||||
immersionTracking?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
connect: () => void;
|
||||
};
|
||||
|
||||
export interface AppReadyReloadConfigInput {
|
||||
reloadConfigStrict: () =>
|
||||
| { ok: true; path: string; warnings: ConfigValidationWarning[] }
|
||||
| { ok: false; path: string; error: string };
|
||||
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;
|
||||
setExitCode?: (code: number) => void;
|
||||
quit: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppReadyCriticalConfigInput {
|
||||
getConfigPath: () => string;
|
||||
failHandlers: {
|
||||
logError: (details: string) => void;
|
||||
showErrorBox: (title: string, details: string) => void;
|
||||
setExitCode?: (code: number) => void;
|
||||
quit: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppReadyImmersionInput {
|
||||
getResolvedConfig: () => ImmersionTrackingConfigLike;
|
||||
getConfiguredDbPath: () => string;
|
||||
createTrackerService: (params: {
|
||||
dbPath: string;
|
||||
policy: {
|
||||
batchSize: number;
|
||||
flushIntervalMs: number;
|
||||
queueCap: number;
|
||||
payloadCapBytes: number;
|
||||
maintenanceIntervalMs: number;
|
||||
retention: {
|
||||
eventsDays: number;
|
||||
telemetryDays: number;
|
||||
sessionsDays: number;
|
||||
dailyRollupsDays: number;
|
||||
monthlyRollupsDays: number;
|
||||
vacuumIntervalDays: number;
|
||||
};
|
||||
};
|
||||
}) => unknown;
|
||||
setTracker: (tracker: unknown | null) => void;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
shouldAutoConnectMpv?: () => boolean;
|
||||
seedTrackerFromCurrentMedia: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug: (message: string) => void;
|
||||
logWarn: (message: string, details: unknown) => void;
|
||||
}
|
||||
|
||||
export interface AppReadyRunnerInput<TConfig extends AppReadyConfigLike = AppReadyConfigLike> {
|
||||
ensureDefaultConfigBootstrap: () => void;
|
||||
getSubtitlePosition: () => SubtitlePositionLike | null;
|
||||
loadSubtitlePosition: () => void;
|
||||
getKeybindingsCount: () => number;
|
||||
resolveKeybindings: () => void;
|
||||
hasMpvClient: () => boolean;
|
||||
createMpvClient: () => void;
|
||||
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
|
||||
initRuntimeOptionsManager: () => void;
|
||||
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
|
||||
createSubtitleTimingTracker: () => void;
|
||||
getResolvedConfig: () => TConfig;
|
||||
getConfigWarnings: () => ConfigValidationWarning[];
|
||||
logConfigWarning: (warning: ConfigValidationWarning) => void;
|
||||
setLogLevel: (level: string, source: LogLevelSource) => void;
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
defaultSecondarySubMode: SecondarySubMode;
|
||||
defaultWebsocketPort: number;
|
||||
defaultAnnotationWebsocketPort: number;
|
||||
defaultTexthookerPort: number;
|
||||
hasMpvWebsocketPlugin: () => boolean;
|
||||
startSubtitleWebsocket: (port: number) => void;
|
||||
startAnnotationWebsocket: (port: number) => void;
|
||||
startTexthooker: (port: number, websocketUrl?: string) => void;
|
||||
log: (message: string) => void;
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
createImmersionTracker?: () => void;
|
||||
startJellyfinRemoteSession?: () => Promise<void>;
|
||||
loadYomitanExtension: () => Promise<void>;
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
handleFirstRunSetup: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries?: () => Promise<void>;
|
||||
startBackgroundWarmups: () => void;
|
||||
texthookerOnlyMode: boolean;
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
runHeadlessInitialCommand?: () => Promise<void>;
|
||||
handleInitialArgs: () => void;
|
||||
onCriticalConfigErrors?: (errors: string[]) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
now?: () => number;
|
||||
shouldRunHeadlessInitialCommand?: () => boolean;
|
||||
shouldUseMinimalStartup?: () => boolean;
|
||||
shouldSkipHeavyStartup?: () => boolean;
|
||||
}
|
||||
|
||||
export interface AppReadyRuntimeInput<TConfig extends AppReadyConfigLike = AppReadyConfigLike> {
|
||||
reload: AppReadyReloadConfigInput;
|
||||
criticalConfig: AppReadyCriticalConfigInput;
|
||||
immersion: AppReadyImmersionInput;
|
||||
runner: AppReadyRunnerInput<TConfig>;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
}
|
||||
|
||||
export interface AppReadyRuntime {
|
||||
reloadConfig: () => void;
|
||||
criticalConfigError: (errors: string[]) => never;
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
|
||||
runAppReady: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createAppReadyRuntime<TConfig extends AppReadyConfigLike>(
|
||||
input: AppReadyRuntimeInput<TConfig>,
|
||||
): AppReadyRuntime {
|
||||
const ensureSubtitlePositionLoaded = (): void => {
|
||||
if (input.runner.getSubtitlePosition() === null) {
|
||||
input.runner.loadSubtitlePosition();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureKeybindingsResolved = (): void => {
|
||||
if (input.runner.getKeybindingsCount() === 0) {
|
||||
input.runner.resolveKeybindings();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureMpvClientCreated = (): void => {
|
||||
if (!input.runner.hasMpvClient()) {
|
||||
input.runner.createMpvClient();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureRuntimeOptionsManagerInitialized = (): void => {
|
||||
if (!input.runner.getRuntimeOptionsManager()) {
|
||||
input.runner.initRuntimeOptionsManager();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureSubtitleTimingTrackerCreated = (): void => {
|
||||
if (!input.runner.getSubtitleTimingTracker()) {
|
||||
input.runner.createSubtitleTimingTracker();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureOverlayStartupPrereqs = (): void => {
|
||||
ensureSubtitlePositionLoaded();
|
||||
ensureKeybindingsResolved();
|
||||
ensureMpvClientCreated();
|
||||
ensureRuntimeOptionsManagerInitialized();
|
||||
ensureSubtitleTimingTrackerCreated();
|
||||
};
|
||||
|
||||
const ensureYoutubePlaybackRuntimeReady = async (): Promise<void> => {
|
||||
ensureOverlayStartupPrereqs();
|
||||
await input.runner.ensureYomitanExtensionLoaded();
|
||||
if (!input.isOverlayRuntimeInitialized()) {
|
||||
input.runner.initializeOverlayRuntime();
|
||||
return;
|
||||
}
|
||||
input.runner.ensureOverlayWindowsReadyForVisibilityActions();
|
||||
};
|
||||
|
||||
const createImmersionTracker = input.runner.createImmersionTracker;
|
||||
const startJellyfinRemoteSession = input.runner.startJellyfinRemoteSession;
|
||||
const prewarmSubtitleDictionaries = input.runner.prewarmSubtitleDictionaries;
|
||||
const runHeadlessInitialCommand = input.runner.runHeadlessInitialCommand;
|
||||
|
||||
const { reloadConfig, criticalConfigError, appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
reloadConfigMainDeps: input.reload,
|
||||
criticalConfigErrorMainDeps: input.criticalConfig,
|
||||
immersionTrackerStartupMainDeps: input.immersion as never,
|
||||
appReadyRuntimeMainDeps: {
|
||||
ensureDefaultConfigBootstrap: () => input.runner.ensureDefaultConfigBootstrap(),
|
||||
loadSubtitlePosition: () => ensureSubtitlePositionLoaded(),
|
||||
resolveKeybindings: () => ensureKeybindingsResolved(),
|
||||
createMpvClient: () => ensureMpvClientCreated(),
|
||||
initRuntimeOptionsManager: () => ensureRuntimeOptionsManagerInitialized(),
|
||||
createSubtitleTimingTracker: () => ensureSubtitleTimingTrackerCreated(),
|
||||
getResolvedConfig: () => input.runner.getResolvedConfig() as never,
|
||||
getConfigWarnings: () => input.runner.getConfigWarnings(),
|
||||
logConfigWarning: (warning) => input.runner.logConfigWarning(warning),
|
||||
setLogLevel: (level, source) => input.runner.setLogLevel(level, source),
|
||||
setSecondarySubMode: (mode) => input.runner.setSecondarySubMode(mode),
|
||||
defaultSecondarySubMode: input.runner.defaultSecondarySubMode,
|
||||
defaultWebsocketPort: input.runner.defaultWebsocketPort,
|
||||
defaultAnnotationWebsocketPort: input.runner.defaultAnnotationWebsocketPort,
|
||||
defaultTexthookerPort: input.runner.defaultTexthookerPort,
|
||||
hasMpvWebsocketPlugin: () => input.runner.hasMpvWebsocketPlugin(),
|
||||
startSubtitleWebsocket: (port) => input.runner.startSubtitleWebsocket(port),
|
||||
startAnnotationWebsocket: (port) => input.runner.startAnnotationWebsocket(port),
|
||||
startTexthooker: (port, websocketUrl) => input.runner.startTexthooker(port, websocketUrl),
|
||||
log: (message) => input.runner.log(message),
|
||||
createMecabTokenizerAndCheck: () => input.runner.createMecabTokenizerAndCheck(),
|
||||
createImmersionTracker: createImmersionTracker ? () => createImmersionTracker() : undefined,
|
||||
startJellyfinRemoteSession: startJellyfinRemoteSession
|
||||
? () => startJellyfinRemoteSession()
|
||||
: undefined,
|
||||
loadYomitanExtension: () => input.runner.loadYomitanExtension(),
|
||||
handleFirstRunSetup: () => input.runner.handleFirstRunSetup(),
|
||||
prewarmSubtitleDictionaries: prewarmSubtitleDictionaries
|
||||
? () => prewarmSubtitleDictionaries()
|
||||
: undefined,
|
||||
startBackgroundWarmups: () => input.runner.startBackgroundWarmups(),
|
||||
texthookerOnlyMode: input.runner.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||
input.runner.shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||
setVisibleOverlayVisible: (visible) => input.runner.setVisibleOverlayVisible(visible),
|
||||
initializeOverlayRuntime: () => input.runner.initializeOverlayRuntime(),
|
||||
runHeadlessInitialCommand: runHeadlessInitialCommand
|
||||
? () => runHeadlessInitialCommand()
|
||||
: undefined,
|
||||
handleInitialArgs: () => input.runner.handleInitialArgs(),
|
||||
logDebug: input.runner.logDebug,
|
||||
now: input.runner.now,
|
||||
shouldRunHeadlessInitialCommand: input.runner.shouldRunHeadlessInitialCommand,
|
||||
shouldUseMinimalStartup: input.runner.shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: input.runner.shouldSkipHeavyStartup,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
reloadConfig,
|
||||
criticalConfigError,
|
||||
ensureOverlayStartupPrereqs,
|
||||
ensureYoutubePlaybackRuntimeReady,
|
||||
runAppReady: async () => {
|
||||
await appReadyRuntimeRunner();
|
||||
},
|
||||
};
|
||||
}
|
||||
94
src/main/cli-startup-runtime.test.ts
Normal file
94
src/main/cli-startup-runtime.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { CliArgs } from '../cli/args';
|
||||
|
||||
import { createCliStartupRuntime } from './cli-startup-runtime';
|
||||
|
||||
test('cli startup runtime returns callable CLI handlers', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createCliStartupRuntime({
|
||||
appState: {
|
||||
appState: {} as never,
|
||||
getInitialArgs: () => null,
|
||||
isBackgroundMode: () => false,
|
||||
isTexthookerOnlyMode: () => false,
|
||||
setTexthookerOnlyMode: () => {},
|
||||
hasImmersionTracker: () => false,
|
||||
getMpvClient: () => null,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
},
|
||||
config: {
|
||||
defaultConfig: { websocket: { port: 6677 }, annotationWebsocket: { port: 6678 } } as never,
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
setCliLogLevel: () => {},
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
},
|
||||
io: {
|
||||
texthookerService: {} as never,
|
||||
openExternal: async () => {},
|
||||
logBrowserOpenError: () => {},
|
||||
showMpvOsd: () => {},
|
||||
schedule: () => 0 as never,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
},
|
||||
commands: {
|
||||
initializeOverlayRuntime: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
openFirstRunSetupWindow: () => {},
|
||||
setVisibleOverlayVisible: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
mineSentenceCard: async () => {},
|
||||
startPendingMineSentenceMultiple: () => {},
|
||||
updateLastCardFromClipboard: async () => {},
|
||||
refreshKnownWordCache: async () => {},
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetupWindow: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
|
||||
generateCharacterDictionary: async () => ({
|
||||
zipPath: '/tmp/test.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 1,
|
||||
}),
|
||||
runJellyfinCommand: async () => {},
|
||||
runStatsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
stopApp: () => {},
|
||||
hasMainWindow: () => false,
|
||||
getMultiCopyTimeoutMs: () => 0,
|
||||
},
|
||||
startup: {
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {},
|
||||
commandNeedsOverlayStartupPrereqs: () => false,
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (args) => {
|
||||
calls.push(`handle:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof runtime.handleCliCommand, 'function');
|
||||
assert.equal(typeof runtime.handleInitialArgs, 'function');
|
||||
|
||||
runtime.handleCliCommand({ command: 'start' } as unknown as CliArgs);
|
||||
assert.deepEqual(calls, ['handle:start']);
|
||||
});
|
||||
220
src/main/cli-startup-runtime.ts
Normal file
220
src/main/cli-startup-runtime.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
||||
import type {
|
||||
CliCommandRuntimeServiceContext,
|
||||
CliCommandRuntimeServiceContextHandlers,
|
||||
} from './cli-runtime';
|
||||
import { composeCliStartupHandlers } from './runtime/composers/cli-startup-composer';
|
||||
|
||||
/** Mpv client shape required by the CLI command context (appState.mpvClient). */
|
||||
type AppStateMpvClientLike = {
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
connect: () => void;
|
||||
} | null;
|
||||
|
||||
/** Mpv client shape required by the initial-args handler (getMpvClient). */
|
||||
type InitialArgsMpvClientLike = { connected: boolean; connect: () => void } | null;
|
||||
|
||||
/** Resolved config shape consumed by the CLI command context builder. */
|
||||
type ResolvedConfigLike = {
|
||||
texthooker?: { openBrowser?: boolean };
|
||||
websocket?: { enabled?: boolean | 'auto'; port?: number };
|
||||
annotationWebsocket?: { enabled?: boolean; port?: number };
|
||||
};
|
||||
|
||||
/** Mutable app state consumed by the CLI command context builder. */
|
||||
type CliCommandContextMainStateLike = {
|
||||
mpvSocketPath: string;
|
||||
mpvClient: AppStateMpvClientLike;
|
||||
texthookerPort: number;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
};
|
||||
|
||||
export interface CliStartupAppStateInput {
|
||||
appState: CliCommandContextMainStateLike;
|
||||
getInitialArgs: () => CliArgs | null | undefined;
|
||||
isBackgroundMode: () => boolean;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
setTexthookerOnlyMode: (enabled: boolean) => void;
|
||||
hasImmersionTracker: () => boolean;
|
||||
getMpvClient: () => InitialArgsMpvClientLike;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
}
|
||||
|
||||
export interface CliStartupConfigInput {
|
||||
defaultConfig: {
|
||||
websocket: { port: number };
|
||||
annotationWebsocket: { port: number };
|
||||
};
|
||||
getResolvedConfig: () => ResolvedConfigLike;
|
||||
setCliLogLevel: (level: NonNullable<CliArgs['logLevel']>) => void;
|
||||
hasMpvWebsocketPlugin: () => boolean;
|
||||
}
|
||||
|
||||
export interface CliStartupIoInput {
|
||||
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
logBrowserOpenError: (url: string, error: unknown) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
}
|
||||
|
||||
export interface CliStartupCommandsInput {
|
||||
initializeOverlayRuntime: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus'];
|
||||
clearAnilistToken: () => void;
|
||||
openAnilistSetupWindow: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
getAnilistQueueStatus: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
|
||||
processNextAnilistRetryUpdate: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||
runJellyfinCommand: (argsFromCommand: CliArgs) => Promise<void>;
|
||||
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
url: string;
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
source: CliCommandSource;
|
||||
}) => Promise<void>;
|
||||
openYomitanSettings: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
stopApp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
}
|
||||
|
||||
export interface CliStartupStartupInput {
|
||||
shouldEnsureTrayOnStartup: () => boolean;
|
||||
shouldRunHeadlessInitialCommand: (args: CliArgs) => boolean;
|
||||
ensureTray: () => void;
|
||||
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
}
|
||||
|
||||
export interface CliStartupRuntimeInput {
|
||||
appState: CliStartupAppStateInput;
|
||||
config: CliStartupConfigInput;
|
||||
io: CliStartupIoInput;
|
||||
commands: CliStartupCommandsInput;
|
||||
startup: CliStartupStartupInput;
|
||||
handleCliCommandRuntimeServiceWithContext: (
|
||||
args: CliArgs,
|
||||
source: CliCommandSource,
|
||||
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface CliStartupRuntime {
|
||||
handleCliCommand: (args: CliArgs, source?: CliCommandSource) => void;
|
||||
handleInitialArgs: () => void;
|
||||
}
|
||||
|
||||
export function createCliStartupRuntime(input: CliStartupRuntimeInput): CliStartupRuntime {
|
||||
const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
|
||||
cliCommandContextMainDeps: {
|
||||
appState: input.appState.appState,
|
||||
setLogLevel: (level) => input.config.setCliLogLevel(level),
|
||||
texthookerService: input.io.texthookerService,
|
||||
getResolvedConfig: () => input.config.getResolvedConfig(),
|
||||
defaultWebsocketPort: input.config.defaultConfig.websocket.port,
|
||||
defaultAnnotationWebsocketPort: input.config.defaultConfig.annotationWebsocket.port,
|
||||
hasMpvWebsocketPlugin: () => input.config.hasMpvWebsocketPlugin(),
|
||||
openExternal: (url: string) => input.io.openExternal(url),
|
||||
logBrowserOpenError: (url: string, error: unknown) =>
|
||||
input.io.logBrowserOpenError(url, error),
|
||||
showMpvOsd: (text: string) => input.io.showMpvOsd(text),
|
||||
initializeOverlayRuntime: () => input.commands.initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => input.commands.toggleVisibleOverlay(),
|
||||
openFirstRunSetupWindow: () => input.commands.openFirstRunSetupWindow(),
|
||||
setVisibleOverlayVisible: (visible: boolean) =>
|
||||
input.commands.setVisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => input.commands.copyCurrentSubtitle(),
|
||||
startPendingMultiCopy: (timeoutMs: number) => input.commands.startPendingMultiCopy(timeoutMs),
|
||||
mineSentenceCard: () => input.commands.mineSentenceCard(),
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) =>
|
||||
input.commands.startPendingMineSentenceMultiple(timeoutMs),
|
||||
updateLastCardFromClipboard: () => input.commands.updateLastCardFromClipboard(),
|
||||
refreshKnownWordCache: () => input.commands.refreshKnownWordCache(),
|
||||
triggerFieldGrouping: () => input.commands.triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => input.commands.triggerSubsyncFromConfig(),
|
||||
markLastCardAsAudioCard: () => input.commands.markLastCardAsAudioCard(),
|
||||
getAnilistStatus: () => input.commands.getAnilistStatus(),
|
||||
clearAnilistToken: () => input.commands.clearAnilistToken(),
|
||||
openAnilistSetupWindow: () => input.commands.openAnilistSetupWindow(),
|
||||
openJellyfinSetupWindow: () => input.commands.openJellyfinSetupWindow(),
|
||||
getAnilistQueueStatus: () => input.commands.getAnilistQueueStatus(),
|
||||
processNextAnilistRetryUpdate: () => input.commands.processNextAnilistRetryUpdate(),
|
||||
generateCharacterDictionary: (targetPath?: string) =>
|
||||
input.commands.generateCharacterDictionary(targetPath),
|
||||
runJellyfinCommand: (argsFromCommand: CliArgs) =>
|
||||
input.commands.runJellyfinCommand(argsFromCommand),
|
||||
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
|
||||
input.commands.runStatsCommand(argsFromCommand, source),
|
||||
runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request),
|
||||
openYomitanSettings: () => input.commands.openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => input.commands.openRuntimeOptionsPalette(),
|
||||
printHelp: () => input.commands.printHelp(),
|
||||
stopApp: () => input.commands.stopApp(),
|
||||
hasMainWindow: () => input.commands.hasMainWindow(),
|
||||
getMultiCopyTimeoutMs: () => input.commands.getMultiCopyTimeoutMs(),
|
||||
schedule: (fn: () => void, delayMs: number) => input.io.schedule(fn, delayMs),
|
||||
logInfo: (message: string) => input.io.logInfo(message),
|
||||
logWarn: (message: string) => input.io.logWarn(message),
|
||||
logError: (message: string, err: unknown) => input.io.logError(message, err),
|
||||
},
|
||||
cliCommandRuntimeHandlerMainDeps: {
|
||||
handleTexthookerOnlyModeTransitionMainDeps: {
|
||||
isTexthookerOnlyMode: () => input.appState.isTexthookerOnlyMode(),
|
||||
ensureOverlayStartupPrereqs: () => input.startup.ensureOverlayStartupPrereqs(),
|
||||
setTexthookerOnlyMode: (enabled) => input.appState.setTexthookerOnlyMode(enabled),
|
||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
||||
input.startup.commandNeedsOverlayStartupPrereqs(args),
|
||||
startBackgroundWarmups: () => input.startup.startBackgroundWarmups(),
|
||||
logInfo: (message: string) => input.io.logInfo(message),
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (args, source, context) =>
|
||||
input.handleCliCommandRuntimeServiceWithContext(args, source, context),
|
||||
},
|
||||
initialArgsRuntimeHandlerMainDeps: {
|
||||
getInitialArgs: () => input.appState.getInitialArgs() ?? null,
|
||||
isBackgroundMode: () => input.appState.isBackgroundMode(),
|
||||
shouldEnsureTrayOnStartup: () => input.startup.shouldEnsureTrayOnStartup(),
|
||||
shouldRunHeadlessInitialCommand: (args) =>
|
||||
input.startup.shouldRunHeadlessInitialCommand(args),
|
||||
ensureTray: () => input.startup.ensureTray(),
|
||||
isTexthookerOnlyMode: () => input.appState.isTexthookerOnlyMode(),
|
||||
hasImmersionTracker: () => input.appState.hasImmersionTracker(),
|
||||
getMpvClient: () => input.appState.getMpvClient(),
|
||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
||||
input.startup.commandNeedsOverlayStartupPrereqs(args),
|
||||
commandNeedsOverlayRuntime: (args) => input.startup.commandNeedsOverlayRuntime(args),
|
||||
ensureOverlayStartupPrereqs: () => input.startup.ensureOverlayStartupPrereqs(),
|
||||
isOverlayRuntimeInitialized: () => input.appState.isOverlayRuntimeInitialized(),
|
||||
initializeOverlayRuntime: () => input.commands.initializeOverlayRuntime(),
|
||||
logInfo: (message) => input.io.logInfo(message),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
handleCliCommand,
|
||||
handleInitialArgs,
|
||||
};
|
||||
}
|
||||
12
src/main/default-socket-path.ts
Normal file
12
src/main/default-socket-path.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
createBuildGetDefaultSocketPathMainDepsHandler,
|
||||
createGetDefaultSocketPathHandler,
|
||||
} from './runtime/domains/jellyfin';
|
||||
|
||||
export function createDefaultSocketPathResolver(platform: NodeJS.Platform) {
|
||||
return createGetDefaultSocketPathHandler(
|
||||
createBuildGetDefaultSocketPathMainDepsHandler({
|
||||
platform,
|
||||
})(),
|
||||
);
|
||||
}
|
||||
205
src/main/dictionary-support-runtime-input.ts
Normal file
205
src/main/dictionary-support-runtime-input.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { FrequencyDictionaryLookup, ResolvedConfig } from '../types';
|
||||
import type { JlptLookup } from './jlpt-runtime';
|
||||
import type { DictionarySupportRuntimeInput } from './dictionary-support-runtime';
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './runtime/character-dictionary-auto-sync-notifications';
|
||||
import type { StartupOsdSequencerCharacterDictionaryEvent } from './runtime/startup-osd-sequencer';
|
||||
|
||||
type BrowserWindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
webContents: {
|
||||
send: (channel: string, payload?: unknown) => void;
|
||||
};
|
||||
};
|
||||
|
||||
type ImmersionTrackerLike = {
|
||||
handleMediaChange: (path: string, title: string | null) => void;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
currentVideoPath?: string | null;
|
||||
connected?: boolean;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type StartupOsdSequencerLike = {
|
||||
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
|
||||
};
|
||||
|
||||
export interface DictionarySupportRuntimeInputBuilderInput {
|
||||
env: {
|
||||
platform: NodeJS.Platform;
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
userDataPath: string;
|
||||
appUserDataPath: string;
|
||||
homeDir: string;
|
||||
appDataDir?: string;
|
||||
cwd: string;
|
||||
subtitlePositionsDir: string;
|
||||
defaultImmersionDbPath: string;
|
||||
};
|
||||
config: {
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
};
|
||||
dictionaryState: {
|
||||
setJlptLevelLookup: (lookup: JlptLookup) => void;
|
||||
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
|
||||
};
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
debug: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
media: {
|
||||
isRemoteMediaPath: (mediaPath: string) => boolean;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
setCurrentMediaPath: (mediaPath: string | null) => void;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
setCurrentMediaTitle: (title: string | null) => void;
|
||||
getPendingSubtitlePosition: () => DictionarySupportRuntimeInput['getPendingSubtitlePosition'] extends () => infer T
|
||||
? T
|
||||
: never;
|
||||
clearPendingSubtitlePosition: () => void;
|
||||
setSubtitlePosition: DictionarySupportRuntimeInput['setSubtitlePosition'];
|
||||
};
|
||||
subtitle: {
|
||||
loadSubtitlePosition: DictionarySupportRuntimeInput['loadSubtitlePosition'];
|
||||
invalidateTokenizationCache: () => void;
|
||||
refreshSubtitlePrefetchFromActiveTrack: () => void;
|
||||
refreshCurrentSubtitle: (text: string) => void;
|
||||
getCurrentSubtitleText: () => string;
|
||||
};
|
||||
overlay: {
|
||||
broadcastSubtitlePosition: DictionarySupportRuntimeInput<OverlayHostedModal>['broadcastSubtitlePosition'];
|
||||
broadcastToOverlayWindows: DictionarySupportRuntimeInput<OverlayHostedModal>['broadcastToOverlayWindows'];
|
||||
getMainWindow: () => BrowserWindowLike | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
sendToActiveOverlayWindow: DictionarySupportRuntimeInput<OverlayHostedModal>['sendToActiveOverlayWindow'];
|
||||
};
|
||||
tracker: {
|
||||
getTracker: () => ImmersionTrackerLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
};
|
||||
anilist: {
|
||||
guessAnilistMediaInfo: DictionarySupportRuntimeInput['guessAnilistMediaInfo'];
|
||||
};
|
||||
yomitan: {
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
getYomitanDictionaryInfo: () => Promise<Array<{ title: string; revision?: string | number }>>;
|
||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
||||
upsertYomitanDictionarySettings: (
|
||||
dictionaryTitle: string,
|
||||
profileScope: ResolvedConfig['anilist']['characterDictionary']['profileScope'],
|
||||
) => Promise<boolean>;
|
||||
hasParserWindow: () => boolean;
|
||||
clearParserCaches: () => void;
|
||||
};
|
||||
startup: {
|
||||
getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined;
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
startupOsdSequencer?: StartupOsdSequencerLike;
|
||||
};
|
||||
playback: {
|
||||
isYoutubePlaybackActiveNow: () => boolean;
|
||||
waitForYomitanMutationReady: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDictionarySupportRuntimeInput(
|
||||
input: DictionarySupportRuntimeInputBuilderInput,
|
||||
): DictionarySupportRuntimeInput<OverlayHostedModal> {
|
||||
return {
|
||||
platform: input.env.platform,
|
||||
dirname: input.env.dirname,
|
||||
appPath: input.env.appPath,
|
||||
resourcesPath: input.env.resourcesPath,
|
||||
userDataPath: input.env.userDataPath,
|
||||
appUserDataPath: input.env.appUserDataPath,
|
||||
homeDir: input.env.homeDir,
|
||||
appDataDir: input.env.appDataDir,
|
||||
cwd: input.env.cwd,
|
||||
subtitlePositionsDir: input.env.subtitlePositionsDir,
|
||||
getResolvedConfig: () => input.config.getResolvedConfig(),
|
||||
isJlptEnabled: () => input.config.getResolvedConfig().subtitleStyle.enableJlpt,
|
||||
isFrequencyDictionaryEnabled: () =>
|
||||
input.config.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
getFrequencyDictionarySourcePath: () =>
|
||||
input.config.getResolvedConfig().subtitleStyle.frequencyDictionary.sourcePath,
|
||||
setJlptLevelLookup: (lookup) => input.dictionaryState.setJlptLevelLookup(lookup),
|
||||
setFrequencyRankLookup: (lookup) => input.dictionaryState.setFrequencyRankLookup(lookup),
|
||||
logInfo: (message) => input.logger.info(message),
|
||||
logDebug: (message) => input.logger.debug(message),
|
||||
logWarn: (message) => input.logger.warn(message),
|
||||
isRemoteMediaPath: (mediaPath) => input.media.isRemoteMediaPath(mediaPath),
|
||||
getCurrentMediaPath: () => input.media.getCurrentMediaPath(),
|
||||
setCurrentMediaPath: (mediaPath) => input.media.setCurrentMediaPath(mediaPath),
|
||||
getCurrentMediaTitle: () => input.media.getCurrentMediaTitle(),
|
||||
setCurrentMediaTitle: (title) => input.media.setCurrentMediaTitle(title),
|
||||
getPendingSubtitlePosition: () => input.media.getPendingSubtitlePosition(),
|
||||
loadSubtitlePosition: () => input.subtitle.loadSubtitlePosition(),
|
||||
clearPendingSubtitlePosition: () => input.media.clearPendingSubtitlePosition(),
|
||||
setSubtitlePosition: (position) => input.media.setSubtitlePosition(position),
|
||||
broadcastSubtitlePosition: (position) => input.overlay.broadcastSubtitlePosition(position),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
input.overlay.broadcastToOverlayWindows(channel, payload),
|
||||
getTracker: () => input.tracker.getTracker(),
|
||||
getMpvClient: () => input.tracker.getMpvClient(),
|
||||
defaultImmersionDbPath: input.env.defaultImmersionDbPath,
|
||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
||||
input.anilist.guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||
getCollapsibleSectionOpenState: (section) =>
|
||||
input.config.getResolvedConfig().anilist.characterDictionary.collapsibleSections[section],
|
||||
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
|
||||
isYoutubePlaybackActiveNow: () => input.playback.isYoutubePlaybackActiveNow(),
|
||||
waitForYomitanMutationReady: () => input.playback.waitForYomitanMutationReady(),
|
||||
getYomitanDictionaryInfo: () => input.yomitan.getYomitanDictionaryInfo(),
|
||||
importYomitanDictionary: (zipPath) => input.yomitan.importYomitanDictionary(zipPath),
|
||||
deleteYomitanDictionary: (dictionaryTitle) =>
|
||||
input.yomitan.deleteYomitanDictionary(dictionaryTitle),
|
||||
upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) =>
|
||||
input.yomitan.upsertYomitanDictionarySettings(dictionaryTitle, profileScope),
|
||||
getCharacterDictionaryConfig: () => {
|
||||
const config = input.config.getResolvedConfig().anilist.characterDictionary;
|
||||
return {
|
||||
enabled:
|
||||
config.enabled &&
|
||||
input.yomitan.isCharacterDictionaryEnabled() &&
|
||||
!input.playback.isYoutubePlaybackActiveNow(),
|
||||
maxLoaded: config.maxLoaded,
|
||||
profileScope: config.profileScope,
|
||||
};
|
||||
},
|
||||
notifyCharacterDictionaryAutoSyncStatus: (event) => {
|
||||
notifyCharacterDictionaryAutoSyncStatus(event, {
|
||||
getNotificationType: () => input.startup.getNotificationType(),
|
||||
showOsd: (message) => input.startup.showMpvOsd(message),
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.startup.showDesktopNotification(title, options),
|
||||
startupOsdSequencer: input.startup.startupOsdSequencer,
|
||||
});
|
||||
},
|
||||
characterDictionaryAutoSyncCompleteDeps: {
|
||||
hasParserWindow: () => input.yomitan.hasParserWindow(),
|
||||
clearParserCaches: () => input.yomitan.clearParserCaches(),
|
||||
invalidateTokenizationCache: () => input.subtitle.invalidateTokenizationCache(),
|
||||
refreshSubtitlePrefetch: () => input.subtitle.refreshSubtitlePrefetchFromActiveTrack(),
|
||||
refreshCurrentSubtitle: () =>
|
||||
input.subtitle.refreshCurrentSubtitle(input.subtitle.getCurrentSubtitleText()),
|
||||
logInfo: (message) => input.logger.info(message),
|
||||
},
|
||||
getMainWindow: () => input.overlay.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
|
||||
setVisibleOverlayVisible: (visible) => input.overlay.setVisibleOverlayVisible(visible),
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
input.overlay.getRestoreVisibleOverlayOnModalClose(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
input.overlay.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
};
|
||||
}
|
||||
215
src/main/dictionary-support-runtime.test.ts
Normal file
215
src/main/dictionary-support-runtime.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createDictionarySupportRuntime } from './dictionary-support-runtime';
|
||||
|
||||
function createRuntime() {
|
||||
const state = {
|
||||
currentMediaPath: null as string | null,
|
||||
currentMediaTitle: null as string | null,
|
||||
jlptLookupSet: 0,
|
||||
frequencyLookupSet: 0,
|
||||
trackerCalls: [] as Array<{ path: string; title: string | null }>,
|
||||
characterDictionaryConfig: {
|
||||
enabled: false,
|
||||
maxLoaded: 1,
|
||||
profileScope: 'global',
|
||||
},
|
||||
youtubePlaybackActive: false,
|
||||
};
|
||||
|
||||
const runtime = createDictionarySupportRuntime({
|
||||
platform: 'darwin',
|
||||
dirname: '/repo/dist/main',
|
||||
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
|
||||
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
|
||||
userDataPath: '/Users/a/Library/Application Support/SubMiner',
|
||||
appUserDataPath: '/Users/a/Library/Application Support/SubMiner',
|
||||
homeDir: '/Users/a',
|
||||
cwd: '/repo',
|
||||
subtitlePositionsDir: '/Users/a/Library/Application Support/SubMiner/subtitle-positions',
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
subtitleStyle: {
|
||||
enableJlpt: false,
|
||||
frequencyDictionary: {
|
||||
enabled: false,
|
||||
sourcePath: '',
|
||||
},
|
||||
},
|
||||
anilist: {
|
||||
characterDictionary: {
|
||||
enabled: false,
|
||||
maxLoaded: 1,
|
||||
profileScope: 'global',
|
||||
collapsibleSections: {
|
||||
description: false,
|
||||
glossary: false,
|
||||
termEntry: false,
|
||||
nameReading: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
ankiConnect: {
|
||||
behavior: {
|
||||
notificationType: 'none',
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
isJlptEnabled: () => false,
|
||||
isFrequencyDictionaryEnabled: () => false,
|
||||
getFrequencyDictionarySourcePath: () => undefined,
|
||||
setJlptLevelLookup: () => {
|
||||
state.jlptLookupSet += 1;
|
||||
},
|
||||
setFrequencyRankLookup: () => {
|
||||
state.frequencyLookupSet += 1;
|
||||
},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
isRemoteMediaPath: (mediaPath) => mediaPath.startsWith('remote:'),
|
||||
getCurrentMediaPath: () => state.currentMediaPath,
|
||||
setCurrentMediaPath: (mediaPath) => {
|
||||
state.currentMediaPath = mediaPath;
|
||||
},
|
||||
getCurrentMediaTitle: () => state.currentMediaTitle,
|
||||
setCurrentMediaTitle: (title) => {
|
||||
state.currentMediaTitle = title;
|
||||
},
|
||||
getPendingSubtitlePosition: () => null,
|
||||
loadSubtitlePosition: () => null,
|
||||
clearPendingSubtitlePosition: () => {},
|
||||
setSubtitlePosition: () => {},
|
||||
broadcastSubtitlePosition: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
getTracker: () =>
|
||||
({
|
||||
handleMediaChange: (path: string, title: string | null) => {
|
||||
state.trackerCalls.push({ path, title });
|
||||
},
|
||||
}) as never,
|
||||
getMpvClient: () => null,
|
||||
defaultImmersionDbPath: '/tmp/immersion.db',
|
||||
guessAnilistMediaInfo: async () => null,
|
||||
getCollapsibleSectionOpenState: () => false,
|
||||
isCharacterDictionaryEnabled: () => state.characterDictionaryConfig.enabled,
|
||||
isYoutubePlaybackActiveNow: () => state.youtubePlaybackActive,
|
||||
waitForYomitanMutationReady: async () => {},
|
||||
getYomitanDictionaryInfo: async () => [],
|
||||
importYomitanDictionary: async () => false,
|
||||
deleteYomitanDictionary: async () => false,
|
||||
upsertYomitanDictionarySettings: async () => false,
|
||||
getCharacterDictionaryConfig: () => state.characterDictionaryConfig as never,
|
||||
notifyCharacterDictionaryAutoSyncStatus: () => {},
|
||||
characterDictionaryAutoSyncCompleteDeps: {
|
||||
hasParserWindow: () => false,
|
||||
clearParserCaches: () => {},
|
||||
invalidateTokenizationCache: () => {},
|
||||
refreshSubtitlePrefetch: () => {},
|
||||
refreshCurrentSubtitle: () => {},
|
||||
logInfo: () => {},
|
||||
},
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<string>(),
|
||||
sendToActiveOverlayWindow: () => true,
|
||||
});
|
||||
|
||||
return { runtime, state };
|
||||
}
|
||||
|
||||
test('dictionary support runtime wires field grouping resolver and callback', async () => {
|
||||
const { runtime } = createRuntime();
|
||||
const choice = {
|
||||
keepNoteId: 1,
|
||||
deleteNoteId: 2,
|
||||
deleteDuplicate: false,
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
const callback = runtime.createFieldGroupingCallback();
|
||||
const pending = callback({} as never);
|
||||
const resolver = runtime.getFieldGroupingResolver();
|
||||
assert.ok(resolver);
|
||||
resolver(choice as never);
|
||||
assert.deepEqual(await pending, choice);
|
||||
assert.equal(typeof runtime.getFieldGroupingResolver(), 'function');
|
||||
|
||||
runtime.setFieldGroupingResolver(null);
|
||||
assert.equal(runtime.getFieldGroupingResolver(), null);
|
||||
});
|
||||
|
||||
test('dictionary support runtime resolves media paths and keeps title in sync', () => {
|
||||
const { runtime, state } = createRuntime();
|
||||
|
||||
runtime.updateCurrentMediaTitle(' Example Title ');
|
||||
runtime.updateCurrentMediaPath('remote://media' as never);
|
||||
assert.equal(state.currentMediaTitle, 'Example Title');
|
||||
assert.equal(runtime.resolveMediaPathForJimaku('remote://media'), 'Example Title');
|
||||
|
||||
runtime.updateCurrentMediaPath('local.mp4' as never);
|
||||
assert.equal(state.currentMediaTitle, null);
|
||||
assert.equal(runtime.resolveMediaPathForJimaku('remote://media'), 'remote://media');
|
||||
});
|
||||
|
||||
test('dictionary support runtime skips disabled lookup and sync paths', async () => {
|
||||
const { runtime, state } = createRuntime();
|
||||
|
||||
await runtime.ensureJlptDictionaryLookup();
|
||||
await runtime.ensureFrequencyDictionaryLookup();
|
||||
runtime.scheduleCharacterDictionarySync();
|
||||
|
||||
assert.equal(state.jlptLookupSet, 0);
|
||||
assert.equal(state.frequencyLookupSet, 0);
|
||||
});
|
||||
|
||||
test('dictionary support runtime syncs immersion media from current state', async () => {
|
||||
const { runtime, state } = createRuntime();
|
||||
|
||||
runtime.updateCurrentMediaTitle(' Example Title ');
|
||||
runtime.updateCurrentMediaPath('remote://media' as never);
|
||||
await runtime.seedImmersionMediaFromCurrentMedia();
|
||||
runtime.syncImmersionMediaState();
|
||||
|
||||
assert.deepEqual(state.trackerCalls, [
|
||||
{ path: 'remote://media', title: 'Example Title' },
|
||||
{ path: 'remote://media', title: 'Example Title' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('dictionary support runtime gates character dictionary auto-sync scheduling', () => {
|
||||
const { runtime, state } = createRuntime();
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
let timeoutCalls = 0;
|
||||
|
||||
try {
|
||||
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: never[]) => {
|
||||
timeoutCalls += 1;
|
||||
return originalSetTimeout(handler, timeout ?? 0, ...args);
|
||||
}) as typeof globalThis.setTimeout;
|
||||
globalThis.clearTimeout = ((handle: number | NodeJS.Timeout | undefined) => {
|
||||
originalClearTimeout(handle);
|
||||
}) as typeof globalThis.clearTimeout;
|
||||
|
||||
runtime.scheduleCharacterDictionarySync();
|
||||
assert.equal(timeoutCalls, 0);
|
||||
|
||||
state.characterDictionaryConfig = {
|
||||
enabled: true,
|
||||
maxLoaded: 1,
|
||||
profileScope: 'global',
|
||||
};
|
||||
runtime.scheduleCharacterDictionarySync();
|
||||
assert.equal(timeoutCalls, 1);
|
||||
|
||||
state.youtubePlaybackActive = true;
|
||||
runtime.scheduleCharacterDictionarySync();
|
||||
assert.equal(timeoutCalls, 1);
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
}
|
||||
});
|
||||
306
src/main/dictionary-support-runtime.ts
Normal file
306
src/main/dictionary-support-runtime.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import * as path from 'path';
|
||||
|
||||
import type {
|
||||
AnilistCharacterDictionaryProfileScope,
|
||||
FrequencyDictionaryLookup,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuFieldGroupingRequestData,
|
||||
SubtitlePosition,
|
||||
ResolvedConfig,
|
||||
} from '../types';
|
||||
import {
|
||||
createBuildDictionaryRootsMainHandler,
|
||||
createBuildFrequencyDictionaryRuntimeMainDepsHandler,
|
||||
createBuildJlptDictionaryRuntimeMainDepsHandler,
|
||||
} from './runtime/dictionary-runtime-main-deps';
|
||||
import { createImmersionMediaRuntime } from './runtime/immersion-media';
|
||||
import {
|
||||
createFrequencyDictionaryRuntimeService,
|
||||
getFrequencyDictionarySearchPaths,
|
||||
} from './frequency-dictionary-runtime';
|
||||
import {
|
||||
createJlptDictionaryRuntimeService,
|
||||
getJlptDictionarySearchPaths,
|
||||
type JlptLookup,
|
||||
} from './jlpt-runtime';
|
||||
import { createMediaRuntimeService } from './media-runtime';
|
||||
import {
|
||||
createCharacterDictionaryRuntimeService,
|
||||
type CharacterDictionaryBuildResult,
|
||||
} from './character-dictionary-runtime';
|
||||
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
|
||||
import {
|
||||
createCharacterDictionaryAutoSyncRuntimeService,
|
||||
type CharacterDictionaryAutoSyncConfig,
|
||||
type CharacterDictionaryAutoSyncStatusEvent,
|
||||
} from './runtime/character-dictionary-auto-sync';
|
||||
import { handleCharacterDictionaryAutoSyncComplete } from './runtime/character-dictionary-auto-sync-completion';
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createFieldGroupingOverlayRuntime } from '../core/services/field-grouping-overlay';
|
||||
|
||||
type FieldGroupingResolver = ((choice: KikuFieldGroupingChoice) => void) | null;
|
||||
|
||||
type BrowserWindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
webContents: {
|
||||
send: (channel: string, payload?: unknown) => void;
|
||||
};
|
||||
};
|
||||
|
||||
type ImmersionTrackerLike = {
|
||||
handleMediaChange: (path: string, title: string | null) => void;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
currentVideoPath?: string | null;
|
||||
connected?: boolean;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type CharacterDictionaryAutoSyncCompleteDeps = {
|
||||
hasParserWindow: () => boolean;
|
||||
clearParserCaches: () => void;
|
||||
invalidateTokenizationCache: () => void;
|
||||
refreshSubtitlePrefetch: () => void;
|
||||
refreshCurrentSubtitle: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
};
|
||||
|
||||
export interface DictionarySupportRuntimeInput<TModal extends string = string> {
|
||||
platform: NodeJS.Platform;
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
userDataPath: string;
|
||||
appUserDataPath: string;
|
||||
homeDir: string;
|
||||
appDataDir?: string;
|
||||
cwd: string;
|
||||
subtitlePositionsDir: string;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
isJlptEnabled: () => boolean;
|
||||
isFrequencyDictionaryEnabled: () => boolean;
|
||||
getFrequencyDictionarySourcePath: () => string | undefined;
|
||||
setJlptLevelLookup: (lookup: JlptLookup) => void;
|
||||
setFrequencyRankLookup: (lookup: FrequencyDictionaryLookup) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
logWarn: (message: string) => void;
|
||||
isRemoteMediaPath: (mediaPath: string) => boolean;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
setCurrentMediaPath: (mediaPath: string | null) => void;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
setCurrentMediaTitle: (title: string | null) => void;
|
||||
getPendingSubtitlePosition: () => SubtitlePosition | null;
|
||||
loadSubtitlePosition: () => SubtitlePosition | null;
|
||||
clearPendingSubtitlePosition: () => void;
|
||||
setSubtitlePosition: (position: SubtitlePosition | null) => void;
|
||||
broadcastSubtitlePosition: (position: SubtitlePosition | null) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload?: unknown) => void;
|
||||
getTracker: () => ImmersionTrackerLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
defaultImmersionDbPath: string;
|
||||
guessAnilistMediaInfo: (
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
) => Promise<AnilistMediaGuess | null>;
|
||||
getCollapsibleSectionOpenState: (
|
||||
section: keyof ResolvedConfig['anilist']['characterDictionary']['collapsibleSections'],
|
||||
) => boolean;
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
isYoutubePlaybackActiveNow: () => boolean;
|
||||
waitForYomitanMutationReady: () => Promise<void>;
|
||||
getYomitanDictionaryInfo: () => Promise<Array<{ title: string; revision?: string | number }>>;
|
||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
||||
upsertYomitanDictionarySettings: (
|
||||
dictionaryTitle: string,
|
||||
profileScope: AnilistCharacterDictionaryProfileScope,
|
||||
) => Promise<boolean>;
|
||||
getCharacterDictionaryConfig: () => CharacterDictionaryAutoSyncConfig;
|
||||
notifyCharacterDictionaryAutoSyncStatus: (event: CharacterDictionaryAutoSyncStatusEvent) => void;
|
||||
characterDictionaryAutoSyncCompleteDeps: CharacterDictionaryAutoSyncCompleteDeps;
|
||||
getMainWindow: () => BrowserWindowLike | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<TModal>;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: { restoreOnModalClose?: TModal },
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
export interface DictionarySupportRuntime {
|
||||
ensureJlptDictionaryLookup: () => Promise<void>;
|
||||
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
||||
getFieldGroupingResolver: () => FieldGroupingResolver;
|
||||
setFieldGroupingResolver: (resolver: FieldGroupingResolver) => void;
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getConfiguredDbPath: () => string;
|
||||
seedImmersionMediaFromCurrentMedia: () => Promise<void>;
|
||||
syncImmersionMediaState: () => void;
|
||||
resolveMediaPathForJimaku: (mediaPath: string | null) => string | null;
|
||||
updateCurrentMediaPath: (mediaPath: unknown) => void;
|
||||
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
|
||||
scheduleCharacterDictionarySync: () => void;
|
||||
generateCharacterDictionaryForCurrentMedia: (
|
||||
targetPath?: string,
|
||||
) => Promise<CharacterDictionaryBuildResult>;
|
||||
}
|
||||
|
||||
export function createDictionarySupportRuntime<TModal extends string>(
|
||||
input: DictionarySupportRuntimeInput<TModal>,
|
||||
): DictionarySupportRuntime {
|
||||
const dictionaryRoots = createBuildDictionaryRootsMainHandler({
|
||||
platform: input.platform,
|
||||
dirname: input.dirname,
|
||||
appPath: input.appPath,
|
||||
resourcesPath: input.resourcesPath,
|
||||
userDataPath: input.userDataPath,
|
||||
appUserDataPath: input.appUserDataPath,
|
||||
homeDir: input.homeDir,
|
||||
appDataDir: input.appDataDir,
|
||||
cwd: input.cwd,
|
||||
joinPath: (...parts: string[]) => path.join(...parts),
|
||||
});
|
||||
|
||||
const jlptRuntime = createJlptDictionaryRuntimeService(
|
||||
createBuildJlptDictionaryRuntimeMainDepsHandler({
|
||||
isJlptEnabled: () => input.isJlptEnabled(),
|
||||
getDictionaryRoots: () => dictionaryRoots(),
|
||||
getJlptDictionarySearchPaths,
|
||||
setJlptLevelLookup: (lookup) => input.setJlptLevelLookup(lookup),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
})(),
|
||||
);
|
||||
|
||||
const frequencyRuntime = createFrequencyDictionaryRuntimeService(
|
||||
createBuildFrequencyDictionaryRuntimeMainDepsHandler({
|
||||
isFrequencyDictionaryEnabled: () => input.isFrequencyDictionaryEnabled(),
|
||||
getDictionaryRoots: () => dictionaryRoots(),
|
||||
getFrequencyDictionarySearchPaths,
|
||||
getSourcePath: () => input.getFrequencyDictionarySourcePath(),
|
||||
setFrequencyRankLookup: (lookup) => input.setFrequencyRankLookup(lookup),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
})(),
|
||||
);
|
||||
|
||||
let fieldGroupingResolver: FieldGroupingResolver = null;
|
||||
let fieldGroupingResolverSequence = 0;
|
||||
|
||||
const getFieldGroupingResolver = (): FieldGroupingResolver => fieldGroupingResolver;
|
||||
const setFieldGroupingResolver = (resolver: FieldGroupingResolver): void => {
|
||||
if (!resolver) {
|
||||
fieldGroupingResolver = null;
|
||||
return;
|
||||
}
|
||||
const sequence = ++fieldGroupingResolverSequence;
|
||||
fieldGroupingResolver = (choice) => {
|
||||
if (sequence !== fieldGroupingResolverSequence) {
|
||||
return;
|
||||
}
|
||||
resolver(choice);
|
||||
};
|
||||
};
|
||||
|
||||
const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<TModal>({
|
||||
getMainWindow: () => input.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => input.getVisibleOverlayVisible(),
|
||||
setVisibleOverlayVisible: (visible) => input.setVisibleOverlayVisible(visible),
|
||||
getResolver: () => getFieldGroupingResolver(),
|
||||
setResolver: (resolver) => setFieldGroupingResolver(resolver),
|
||||
getRestoreVisibleOverlayOnModalClose: () => input.getRestoreVisibleOverlayOnModalClose(),
|
||||
sendToVisibleOverlay: (channel, payload, runtimeOptions) =>
|
||||
input.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
});
|
||||
|
||||
const immersionMediaRuntime = createImmersionMediaRuntime({
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
defaultImmersionDbPath: input.defaultImmersionDbPath,
|
||||
getTracker: () => input.getTracker(),
|
||||
getMpvClient: () => input.getMpvClient(),
|
||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
||||
logDebug: (message) => (input.logDebug ?? input.logInfo)(message),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
});
|
||||
|
||||
const mediaRuntime = createMediaRuntimeService({
|
||||
isRemoteMediaPath: (mediaPath) => input.isRemoteMediaPath(mediaPath),
|
||||
loadSubtitlePosition: () => input.loadSubtitlePosition(),
|
||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
||||
getPendingSubtitlePosition: () => input.getPendingSubtitlePosition(),
|
||||
getSubtitlePositionsDir: () => input.subtitlePositionsDir,
|
||||
setCurrentMediaPath: (mediaPath) => input.setCurrentMediaPath(mediaPath),
|
||||
clearPendingSubtitlePosition: () => input.clearPendingSubtitlePosition(),
|
||||
setSubtitlePosition: (position) => input.setSubtitlePosition(position),
|
||||
broadcastSubtitlePosition: (position) => input.broadcastSubtitlePosition(position),
|
||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
||||
setCurrentMediaTitle: (title) => input.setCurrentMediaTitle(title),
|
||||
});
|
||||
|
||||
const characterDictionaryRuntime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath: input.userDataPath,
|
||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
||||
input.guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||
getCollapsibleSectionOpenState: (section) => input.getCollapsibleSectionOpenState(section),
|
||||
now: () => Date.now(),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logWarn: (message) => input.logWarn(message),
|
||||
});
|
||||
|
||||
const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||
userDataPath: input.userDataPath,
|
||||
getConfig: () => input.getCharacterDictionaryConfig(),
|
||||
getOrCreateCurrentSnapshot: (targetPath, progress) =>
|
||||
characterDictionaryRuntime.getOrCreateCurrentSnapshot(targetPath, progress),
|
||||
buildMergedDictionary: (mediaIds) => characterDictionaryRuntime.buildMergedDictionary(mediaIds),
|
||||
waitForYomitanMutationReady: () => input.waitForYomitanMutationReady(),
|
||||
getYomitanDictionaryInfo: () => input.getYomitanDictionaryInfo(),
|
||||
importYomitanDictionary: (zipPath) => input.importYomitanDictionary(zipPath),
|
||||
deleteYomitanDictionary: (dictionaryTitle) => input.deleteYomitanDictionary(dictionaryTitle),
|
||||
upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) =>
|
||||
input.upsertYomitanDictionarySettings(dictionaryTitle, profileScope),
|
||||
now: () => Date.now(),
|
||||
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
||||
clearSchedule: (timer) => clearTimeout(timer),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logWarn: (message) => input.logWarn(message),
|
||||
onSyncStatus: (event) => input.notifyCharacterDictionaryAutoSyncStatus(event),
|
||||
onSyncComplete: (result) =>
|
||||
handleCharacterDictionaryAutoSyncComplete(
|
||||
result,
|
||||
input.characterDictionaryAutoSyncCompleteDeps,
|
||||
),
|
||||
});
|
||||
|
||||
const scheduleCharacterDictionarySync = (): void => {
|
||||
if (!input.isCharacterDictionaryEnabled() || input.isYoutubePlaybackActiveNow()) {
|
||||
return;
|
||||
}
|
||||
characterDictionaryAutoSyncRuntime.scheduleSync();
|
||||
};
|
||||
|
||||
return {
|
||||
ensureJlptDictionaryLookup: () => jlptRuntime.ensureJlptDictionaryLookup(),
|
||||
ensureFrequencyDictionaryLookup: () => frequencyRuntime.ensureFrequencyDictionaryLookup(),
|
||||
getFieldGroupingResolver,
|
||||
setFieldGroupingResolver,
|
||||
createFieldGroupingCallback: () => fieldGroupingOverlayRuntime.createFieldGroupingCallback(),
|
||||
getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(),
|
||||
seedImmersionMediaFromCurrentMedia: () => immersionMediaRuntime.seedFromCurrentMedia(),
|
||||
syncImmersionMediaState: () => immersionMediaRuntime.syncFromCurrentMediaState(),
|
||||
resolveMediaPathForJimaku: (mediaPath) => mediaRuntime.resolveMediaPathForJimaku(mediaPath),
|
||||
updateCurrentMediaPath: (mediaPath) => mediaRuntime.updateCurrentMediaPath(mediaPath),
|
||||
updateCurrentMediaTitle: (mediaTitle) => mediaRuntime.updateCurrentMediaTitle(mediaTitle),
|
||||
scheduleCharacterDictionarySync,
|
||||
generateCharacterDictionaryForCurrentMedia: (targetPath?: string) =>
|
||||
characterDictionaryRuntime.generateForCurrentMedia(targetPath),
|
||||
};
|
||||
}
|
||||
55
src/main/discord-presence-lifecycle-runtime.test.ts
Normal file
55
src/main/discord-presence-lifecycle-runtime.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createDiscordPresenceLifecycleRuntime } from './discord-presence-lifecycle-runtime';
|
||||
|
||||
test('discord presence lifecycle runtime starts service and publishes presence when enabled', async () => {
|
||||
const calls: string[] = [];
|
||||
let service: { start: () => Promise<void>; stop: () => Promise<void> } | null = null;
|
||||
|
||||
const runtime = createDiscordPresenceLifecycleRuntime({
|
||||
getResolvedConfig: () => ({ discordPresence: { enabled: true } }),
|
||||
getDiscordPresenceService: () => service as never,
|
||||
setDiscordPresenceService: (next) => {
|
||||
service = next as typeof service;
|
||||
},
|
||||
getMpvClient: () => null,
|
||||
getCurrentMediaTitle: () => 'Demo',
|
||||
getCurrentMediaPath: () => '/tmp/demo.mkv',
|
||||
getCurrentSubtitleText: () => 'subtitle',
|
||||
getPlaybackPaused: () => false,
|
||||
getFallbackMediaDurationSec: () => 12,
|
||||
createDiscordPresenceService: () => ({
|
||||
start: async () => {
|
||||
calls.push('start');
|
||||
},
|
||||
stop: async () => {
|
||||
calls.push('stop');
|
||||
},
|
||||
publish: () => {
|
||||
calls.push('publish');
|
||||
},
|
||||
}),
|
||||
createDiscordRuntime: (input) => ({
|
||||
refreshDiscordPresenceMediaDuration: async () => {},
|
||||
publishDiscordPresence: () => {
|
||||
calls.push(input.getCurrentMediaTitle() ?? 'unknown');
|
||||
input.getDiscordPresenceService()?.publish({
|
||||
mediaTitle: input.getCurrentMediaTitle(),
|
||||
mediaPath: input.getCurrentMediaPath(),
|
||||
subtitleText: input.getCurrentSubtitleText(),
|
||||
currentTimeSec: null,
|
||||
mediaDurationSec: input.getFallbackMediaDurationSec(),
|
||||
paused: input.getPlaybackPaused(),
|
||||
connected: false,
|
||||
sessionStartedAtMs: input.getSessionStartedAtMs(),
|
||||
});
|
||||
},
|
||||
}),
|
||||
now: () => 123,
|
||||
});
|
||||
|
||||
await runtime.initializeDiscordPresenceService();
|
||||
|
||||
assert.deepEqual(calls, ['start', 'Demo', 'publish']);
|
||||
});
|
||||
90
src/main/discord-presence-lifecycle-runtime.ts
Normal file
90
src/main/discord-presence-lifecycle-runtime.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createDiscordPresenceRuntime } from './runtime/discord-presence-runtime';
|
||||
|
||||
type DiscordPresenceConfigLike = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
type DiscordPresenceServiceLike = {
|
||||
start: () => Promise<void>;
|
||||
stop?: () => Promise<void>;
|
||||
publish: (snapshot: {
|
||||
mediaTitle: string | null;
|
||||
mediaPath: string | null;
|
||||
subtitleText: string;
|
||||
currentTimeSec: number | null;
|
||||
mediaDurationSec: number | null;
|
||||
paused: boolean | null;
|
||||
connected: boolean;
|
||||
sessionStartedAtMs: number;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export interface DiscordPresenceLifecycleRuntimeInput {
|
||||
getResolvedConfig: () => { discordPresence: DiscordPresenceConfigLike };
|
||||
getDiscordPresenceService: () => DiscordPresenceServiceLike | null;
|
||||
setDiscordPresenceService: (service: DiscordPresenceServiceLike | null) => void;
|
||||
getMpvClient: () => {
|
||||
connected?: boolean;
|
||||
currentTimePos?: number | null;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
getCurrentMediaTitle: () => string | null;
|
||||
getCurrentMediaPath: () => string | null;
|
||||
getCurrentSubtitleText: () => string;
|
||||
getPlaybackPaused: () => boolean | null;
|
||||
getFallbackMediaDurationSec: () => number | null;
|
||||
createDiscordPresenceService: (config: unknown) => DiscordPresenceServiceLike;
|
||||
createDiscordRuntime?: typeof createDiscordPresenceRuntime;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface DiscordPresenceLifecycleRuntime {
|
||||
publishDiscordPresence: () => void;
|
||||
initializeDiscordPresenceService: () => Promise<void>;
|
||||
stopDiscordPresenceService: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createDiscordPresenceLifecycleRuntime(
|
||||
input: DiscordPresenceLifecycleRuntimeInput,
|
||||
): DiscordPresenceLifecycleRuntime {
|
||||
let discordPresenceMediaDurationSec: number | null = null;
|
||||
const discordPresenceSessionStartedAtMs = input.now ? input.now() : Date.now();
|
||||
|
||||
const discordPresenceRuntime = (input.createDiscordRuntime ?? createDiscordPresenceRuntime)({
|
||||
getDiscordPresenceService: () => input.getDiscordPresenceService(),
|
||||
isDiscordPresenceEnabled: () => input.getResolvedConfig().discordPresence.enabled === true,
|
||||
getMpvClient: () => input.getMpvClient(),
|
||||
getCurrentMediaTitle: () => input.getCurrentMediaTitle(),
|
||||
getCurrentMediaPath: () => input.getCurrentMediaPath(),
|
||||
getCurrentSubtitleText: () => input.getCurrentSubtitleText(),
|
||||
getPlaybackPaused: () => input.getPlaybackPaused(),
|
||||
getFallbackMediaDurationSec: () => input.getFallbackMediaDurationSec(),
|
||||
getSessionStartedAtMs: () => discordPresenceSessionStartedAtMs,
|
||||
getMediaDurationSec: () => discordPresenceMediaDurationSec,
|
||||
setMediaDurationSec: (next) => {
|
||||
discordPresenceMediaDurationSec = next;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
publishDiscordPresence: () => {
|
||||
discordPresenceRuntime.publishDiscordPresence();
|
||||
},
|
||||
initializeDiscordPresenceService: async () => {
|
||||
if (input.getResolvedConfig().discordPresence.enabled !== true) {
|
||||
input.setDiscordPresenceService(null);
|
||||
return;
|
||||
}
|
||||
|
||||
input.setDiscordPresenceService(
|
||||
input.createDiscordPresenceService(input.getResolvedConfig().discordPresence),
|
||||
);
|
||||
await input.getDiscordPresenceService()?.start();
|
||||
discordPresenceRuntime.publishDiscordPresence();
|
||||
},
|
||||
stopDiscordPresenceService: async () => {
|
||||
await input.getDiscordPresenceService()?.stop?.();
|
||||
input.setDiscordPresenceService(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
92
src/main/first-run-runtime-coordinator.ts
Normal file
92
src/main/first-run-runtime-coordinator.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import { getYomitanDictionaryInfo } from '../core/services';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import { createFirstRunRuntime } from './first-run-runtime';
|
||||
import type { AppState } from './state';
|
||||
|
||||
export interface FirstRunRuntimeCoordinatorInput {
|
||||
platform: NodeJS.Platform;
|
||||
configDir: string;
|
||||
homeDir: string;
|
||||
xdgConfigHome?: string;
|
||||
binaryPath: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
appDataDir: string;
|
||||
desktopDir: string;
|
||||
appState: Pick<AppState, 'firstRunSetupWindow' | 'firstRunSetupCompleted' | 'backgroundMode'>;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
yomitan: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
getParserRuntimeDeps: () => Parameters<typeof getYomitanDictionaryInfo>[0];
|
||||
openYomitanSettings: () => boolean;
|
||||
};
|
||||
overlay: {
|
||||
ensureTray: () => void;
|
||||
hasTray: () => boolean;
|
||||
};
|
||||
actions: {
|
||||
writeShortcutLink: (
|
||||
shortcutPath: string,
|
||||
operation: 'create' | 'update' | 'replace',
|
||||
details: {
|
||||
target: string;
|
||||
args?: string;
|
||||
cwd?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
iconIndex?: number;
|
||||
},
|
||||
) => boolean;
|
||||
requestAppQuit: () => void;
|
||||
};
|
||||
logger: {
|
||||
error: (message: string, error: unknown) => void;
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function createFirstRunRuntimeCoordinator(input: FirstRunRuntimeCoordinatorInput) {
|
||||
return createFirstRunRuntime<BrowserWindow>({
|
||||
platform: input.platform,
|
||||
configDir: input.configDir,
|
||||
homeDir: input.homeDir,
|
||||
xdgConfigHome: input.xdgConfigHome,
|
||||
binaryPath: input.binaryPath,
|
||||
appPath: input.appPath,
|
||||
resourcesPath: input.resourcesPath,
|
||||
appDataDir: input.appDataDir,
|
||||
desktopDir: input.desktopDir,
|
||||
getYomitanDictionaryCount: async () => {
|
||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
||||
const dictionaries = await getYomitanDictionaryInfo(input.yomitan.getParserRuntimeDeps(), {
|
||||
error: (message, ...args) => input.logger.error(message, args[0]),
|
||||
info: (message, ...args) => input.logger.info(message, ...args),
|
||||
});
|
||||
return dictionaries.length;
|
||||
},
|
||||
isExternalYomitanConfigured: () =>
|
||||
input.getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
||||
createBrowserWindow: (options) => {
|
||||
const window = new BrowserWindow(options);
|
||||
input.appState.firstRunSetupWindow = window;
|
||||
window.on('closed', () => {
|
||||
input.appState.firstRunSetupWindow = null;
|
||||
});
|
||||
return window;
|
||||
},
|
||||
writeShortcutLink: (shortcutPath, operation, details) =>
|
||||
input.actions.writeShortcutLink(shortcutPath, operation, details),
|
||||
openYomitanSettings: () => input.yomitan.openYomitanSettings(),
|
||||
shouldQuitWhenClosedIncomplete: () => !input.appState.backgroundMode,
|
||||
quitApp: () => input.actions.requestAppQuit(),
|
||||
logError: (message, error) => input.logger.error(message, error),
|
||||
onStateChanged: (state) => {
|
||||
input.appState.firstRunSetupCompleted = state.status === 'completed';
|
||||
if (input.overlay.hasTray()) {
|
||||
input.overlay.ensureTray();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
155
src/main/first-run-runtime.test.ts
Normal file
155
src/main/first-run-runtime.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createFirstRunRuntime } from './first-run-runtime';
|
||||
|
||||
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-runtime-test-'));
|
||||
const result = fn(dir);
|
||||
if (result instanceof Promise) {
|
||||
return result.finally(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function createMockSetupWindow() {
|
||||
const calls: string[] = [];
|
||||
let closedHandler: (() => void) | null = null;
|
||||
let navigateHandler: ((event: { preventDefault: () => void }, url: string) => void) | null = null;
|
||||
|
||||
return {
|
||||
calls,
|
||||
window: {
|
||||
webContents: {
|
||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => {
|
||||
if (event === 'will-navigate') {
|
||||
navigateHandler = handler;
|
||||
}
|
||||
},
|
||||
},
|
||||
loadURL: async (url: string) => {
|
||||
calls.push(`load:${url.slice(0, 24)}`);
|
||||
},
|
||||
on: (event: 'closed', handler: () => void) => {
|
||||
if (event === 'closed') {
|
||||
closedHandler = handler;
|
||||
}
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
close: () => {
|
||||
calls.push('close');
|
||||
closedHandler?.();
|
||||
},
|
||||
focus: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
triggerNavigate: (url: string) => {
|
||||
navigateHandler?.(
|
||||
{
|
||||
preventDefault: () => {
|
||||
calls.push('prevent-default');
|
||||
},
|
||||
},
|
||||
url,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('first-run runtime focuses an existing window instead of creating a new one', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
let createCount = 0;
|
||||
const mock = createMockSetupWindow();
|
||||
const runtime = createFirstRunRuntime({
|
||||
platform: 'darwin',
|
||||
configDir,
|
||||
homeDir: os.homedir(),
|
||||
binaryPath: process.execPath,
|
||||
appPath: '/app',
|
||||
resourcesPath: '/resources',
|
||||
appDataDir: path.join(root, 'appData'),
|
||||
desktopDir: path.join(root, 'desktop'),
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
isExternalYomitanConfigured: () => false,
|
||||
createBrowserWindow: () => {
|
||||
createCount += 1;
|
||||
return mock.window;
|
||||
},
|
||||
writeShortcutLink: () => true,
|
||||
openYomitanSettings: () => false,
|
||||
shouldQuitWhenClosedIncomplete: () => true,
|
||||
quitApp: () => {
|
||||
throw new Error('quit should not be called');
|
||||
},
|
||||
logError: () => {
|
||||
throw new Error('logError should not be called');
|
||||
},
|
||||
});
|
||||
|
||||
runtime.openFirstRunSetupWindow();
|
||||
runtime.openFirstRunSetupWindow();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(createCount, 1);
|
||||
assert.equal(mock.calls.filter((call) => call === 'focus').length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('first-run runtime closes the setup window after completion', async () => {
|
||||
await withTempDir(async (root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
|
||||
|
||||
const events: string[] = [];
|
||||
const mock = createMockSetupWindow();
|
||||
const runtime = createFirstRunRuntime({
|
||||
platform: 'linux',
|
||||
configDir,
|
||||
homeDir: os.homedir(),
|
||||
binaryPath: process.execPath,
|
||||
appPath: '/app',
|
||||
resourcesPath: '/resources',
|
||||
appDataDir: path.join(root, 'appData'),
|
||||
desktopDir: path.join(root, 'desktop'),
|
||||
getYomitanDictionaryCount: async () => 1,
|
||||
isExternalYomitanConfigured: () => false,
|
||||
createBrowserWindow: () => mock.window,
|
||||
writeShortcutLink: () => true,
|
||||
openYomitanSettings: () => false,
|
||||
shouldQuitWhenClosedIncomplete: () => true,
|
||||
quitApp: () => {
|
||||
events.push('quit');
|
||||
},
|
||||
logError: (message, error) => {
|
||||
events.push(`${message}:${String(error)}`);
|
||||
},
|
||||
onStateChanged: (state) => {
|
||||
events.push(state.status);
|
||||
},
|
||||
});
|
||||
|
||||
runtime.openFirstRunSetupWindow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
mock.window.triggerNavigate('subminer://first-run-setup?action=finish');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(runtime.isSetupCompleted(), true);
|
||||
assert.equal(events[0], 'in_progress');
|
||||
assert.equal(events.at(-1), 'completed');
|
||||
assert.equal(mock.calls.includes('close'), true);
|
||||
assert.equal(events.includes('quit'), false);
|
||||
});
|
||||
});
|
||||
235
src/main/first-run-runtime.ts
Normal file
235
src/main/first-run-runtime.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
createFirstRunSetupService,
|
||||
shouldAutoOpenFirstRunSetup,
|
||||
type FirstRunSetupService,
|
||||
type PluginInstallResult,
|
||||
type SetupStatusSnapshot,
|
||||
} from './runtime/first-run-setup-service';
|
||||
import type { SetupState } from '../shared/setup-state';
|
||||
import {
|
||||
buildFirstRunSetupHtml,
|
||||
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
||||
createOpenFirstRunSetupWindowHandler,
|
||||
parseFirstRunSetupSubmissionUrl,
|
||||
} from './runtime/first-run-setup-window';
|
||||
import { createCreateFirstRunSetupWindowHandler } from './runtime/setup-window-factory';
|
||||
import {
|
||||
detectInstalledFirstRunPlugin,
|
||||
installFirstRunPluginToDefaultLocation,
|
||||
syncInstalledFirstRunPluginBinaryPath,
|
||||
} from './runtime/first-run-setup-plugin';
|
||||
import {
|
||||
applyWindowsMpvShortcuts,
|
||||
detectWindowsMpvShortcuts,
|
||||
resolveWindowsMpvShortcutPaths,
|
||||
} from './runtime/windows-mpv-shortcuts';
|
||||
import { resolveDefaultMpvInstallPaths } from '../shared/setup-state';
|
||||
|
||||
export interface FirstRunSetupWindowLike {
|
||||
webContents: {
|
||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
|
||||
};
|
||||
loadURL: (url: string) => Promise<void> | void;
|
||||
on: (event: 'closed', handler: () => void) => void;
|
||||
isDestroyed: () => boolean;
|
||||
close: () => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
export interface FirstRunRuntimeInput<
|
||||
TWindow extends FirstRunSetupWindowLike = FirstRunSetupWindowLike,
|
||||
> {
|
||||
platform: NodeJS.Platform;
|
||||
configDir: string;
|
||||
homeDir: string;
|
||||
xdgConfigHome?: string;
|
||||
binaryPath: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
appDataDir: string;
|
||||
desktopDir: string;
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
isExternalYomitanConfigured: () => boolean;
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
|
||||
writeShortcutLink: (
|
||||
shortcutPath: string,
|
||||
operation: 'create' | 'update' | 'replace',
|
||||
details: {
|
||||
target: string;
|
||||
args?: string;
|
||||
cwd?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
iconIndex?: number;
|
||||
},
|
||||
) => boolean;
|
||||
openYomitanSettings: () => boolean;
|
||||
shouldQuitWhenClosedIncomplete: () => boolean;
|
||||
quitApp: () => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
onStateChanged?: (state: SetupState) => void;
|
||||
}
|
||||
|
||||
export interface FirstRunRuntime {
|
||||
ensureSetupStateInitialized: () => Promise<SetupStatusSnapshot>;
|
||||
isSetupCompleted: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
}
|
||||
|
||||
export function createFirstRunRuntime<TWindow extends FirstRunSetupWindowLike>(
|
||||
input: FirstRunRuntimeInput<TWindow>,
|
||||
): FirstRunRuntime {
|
||||
syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: input.platform,
|
||||
homeDir: input.homeDir,
|
||||
xdgConfigHome: input.xdgConfigHome,
|
||||
binaryPath: input.binaryPath,
|
||||
});
|
||||
|
||||
const firstRunSetupService = createFirstRunSetupService({
|
||||
platform: input.platform,
|
||||
configDir: input.configDir,
|
||||
getYomitanDictionaryCount: input.getYomitanDictionaryCount,
|
||||
isExternalYomitanConfigured: input.isExternalYomitanConfigured,
|
||||
detectPluginInstalled: () =>
|
||||
detectInstalledFirstRunPlugin(
|
||||
resolveDefaultMpvInstallPaths(input.platform, input.homeDir, input.xdgConfigHome),
|
||||
),
|
||||
installPlugin: async (): Promise<PluginInstallResult> =>
|
||||
installFirstRunPluginToDefaultLocation({
|
||||
platform: input.platform,
|
||||
homeDir: input.homeDir,
|
||||
xdgConfigHome: input.xdgConfigHome,
|
||||
dirname: __dirname,
|
||||
appPath: input.appPath,
|
||||
resourcesPath: input.resourcesPath,
|
||||
binaryPath: input.binaryPath,
|
||||
}),
|
||||
detectWindowsMpvShortcuts: async () =>
|
||||
detectWindowsMpvShortcuts(
|
||||
resolveWindowsMpvShortcutPaths({
|
||||
appDataDir: input.appDataDir,
|
||||
desktopDir: input.desktopDir,
|
||||
}),
|
||||
),
|
||||
applyWindowsMpvShortcuts: async (preferences) =>
|
||||
applyWindowsMpvShortcuts({
|
||||
preferences,
|
||||
paths: resolveWindowsMpvShortcutPaths({
|
||||
appDataDir: input.appDataDir,
|
||||
desktopDir: input.desktopDir,
|
||||
}),
|
||||
exePath: input.binaryPath,
|
||||
writeShortcutLink: (shortcutPath, operation, details) =>
|
||||
input.writeShortcutLink(shortcutPath, operation, details),
|
||||
}),
|
||||
onStateChanged: (state) => {
|
||||
input.onStateChanged?.(state);
|
||||
},
|
||||
});
|
||||
|
||||
let firstRunSetupWindow: TWindow | null = null;
|
||||
let firstRunSetupMessage: string | null = null;
|
||||
|
||||
const maybeFocusExistingFirstRunSetupWindow = createMaybeFocusExistingFirstRunSetupWindowHandler({
|
||||
getSetupWindow: () => firstRunSetupWindow,
|
||||
});
|
||||
|
||||
const createSetupWindow = createCreateFirstRunSetupWindowHandler({
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) =>
|
||||
input.createBrowserWindow(options),
|
||||
});
|
||||
|
||||
const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => maybeFocusExistingFirstRunSetupWindow(),
|
||||
createSetupWindow: () => {
|
||||
const window = createSetupWindow();
|
||||
firstRunSetupWindow = window;
|
||||
return window;
|
||||
},
|
||||
getSetupSnapshot: async () => {
|
||||
const snapshot = await firstRunSetupService.getSetupStatus();
|
||||
return {
|
||||
...snapshot,
|
||||
message: firstRunSetupMessage,
|
||||
};
|
||||
},
|
||||
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
|
||||
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
|
||||
handleAction: async (submission) => {
|
||||
if (submission.action === 'install-plugin') {
|
||||
const snapshot = await firstRunSetupService.installMpvPlugin();
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (submission.action === 'configure-windows-mpv-shortcuts') {
|
||||
const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({
|
||||
startMenuEnabled: submission.startMenuEnabled === true,
|
||||
desktopEnabled: submission.desktopEnabled === true,
|
||||
});
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (submission.action === 'open-yomitan-settings') {
|
||||
firstRunSetupMessage = input.openYomitanSettings()
|
||||
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
|
||||
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (submission.action === 'refresh') {
|
||||
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (submission.action === 'skip-plugin') {
|
||||
await firstRunSetupService.skipPluginInstall();
|
||||
firstRunSetupMessage = 'mpv plugin installation skipped.';
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await firstRunSetupService.markSetupCompleted();
|
||||
if (snapshot.state.status === 'completed') {
|
||||
firstRunSetupMessage = null;
|
||||
return { closeWindow: true };
|
||||
}
|
||||
firstRunSetupMessage = 'Install at least one Yomitan dictionary before finishing setup.';
|
||||
return undefined;
|
||||
},
|
||||
markSetupInProgress: async () => {
|
||||
firstRunSetupMessage = null;
|
||||
await firstRunSetupService.markSetupInProgress();
|
||||
},
|
||||
markSetupCancelled: async () => {
|
||||
firstRunSetupMessage = null;
|
||||
await firstRunSetupService.markSetupCancelled();
|
||||
},
|
||||
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
|
||||
shouldQuitWhenClosedIncomplete: () => input.shouldQuitWhenClosedIncomplete(),
|
||||
quitApp: () => input.quitApp(),
|
||||
clearSetupWindow: () => {
|
||||
firstRunSetupWindow = null;
|
||||
},
|
||||
setSetupWindow: (window) => {
|
||||
firstRunSetupWindow = window;
|
||||
},
|
||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
||||
logError: (message, error) => input.logError(message, error),
|
||||
});
|
||||
|
||||
return {
|
||||
ensureSetupStateInitialized: () => firstRunSetupService.ensureSetupStateInitialized(),
|
||||
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => {
|
||||
if (firstRunSetupService.isSetupCompleted()) {
|
||||
return;
|
||||
}
|
||||
openFirstRunSetupWindowHandler();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { shouldAutoOpenFirstRunSetup };
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as path from 'path';
|
||||
import type { FrequencyDictionaryLookup } from '../types';
|
||||
import { createFrequencyDictionaryLookup } from '../core/services';
|
||||
import { createFrequencyDictionaryLookup } from '../core/services/frequency-dictionary';
|
||||
|
||||
export interface FrequencyDictionarySearchPathDeps {
|
||||
getDictionaryRoots: () => string[];
|
||||
|
||||
55
src/main/headless-known-word-refresh.ts
Normal file
55
src/main/headless-known-word-refresh.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { mergeAiConfig } from '../ai/config';
|
||||
import { AnkiIntegration } from '../anki-integration';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
|
||||
export async function runHeadlessKnownWordRefresh(input: {
|
||||
resolvedConfig: ResolvedConfig;
|
||||
runtimeOptionsManager: {
|
||||
getEffectiveAnkiConnectConfig: (config: AnkiConnectConfig) => AnkiConnectConfig;
|
||||
} | null;
|
||||
userDataPath: string;
|
||||
logger: {
|
||||
error: (message: string, error?: unknown) => void;
|
||||
};
|
||||
requestAppQuit: () => void;
|
||||
}): Promise<void> {
|
||||
if (input.resolvedConfig.ankiConnect.enabled !== true) {
|
||||
input.logger.error('Headless known-word refresh failed: AnkiConnect integration not enabled');
|
||||
process.exitCode = 1;
|
||||
input.requestAppQuit();
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveAnkiConfig =
|
||||
input.runtimeOptionsManager?.getEffectiveAnkiConnectConfig(input.resolvedConfig.ankiConnect) ??
|
||||
input.resolvedConfig.ankiConnect;
|
||||
const integration = new AnkiIntegration(
|
||||
effectiveAnkiConfig,
|
||||
new SubtitleTimingTracker(),
|
||||
{ send: () => undefined } as never,
|
||||
undefined,
|
||||
undefined,
|
||||
async () => ({
|
||||
keepNoteId: 0,
|
||||
deleteNoteId: 0,
|
||||
deleteDuplicate: false,
|
||||
cancelled: true,
|
||||
}),
|
||||
path.join(input.userDataPath, 'known-words-cache.json'),
|
||||
mergeAiConfig(input.resolvedConfig.ai, input.resolvedConfig.ankiConnect?.ai),
|
||||
);
|
||||
|
||||
try {
|
||||
await integration.refreshKnownWordCache();
|
||||
} catch (error) {
|
||||
input.logger.error('Headless known-word refresh failed:', error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
integration.stop();
|
||||
input.requestAppQuit();
|
||||
}
|
||||
}
|
||||
131
src/main/headless-startup-runtime.test.ts
Normal file
131
src/main/headless-startup-runtime.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import type { LogLevelSource } from '../logger';
|
||||
import type { StartupBootstrapRuntimeFactoryDeps } from './startup';
|
||||
|
||||
import { createHeadlessStartupRuntime } from './headless-startup-runtime';
|
||||
|
||||
test('headless startup runtime returns callable handlers and applies startup state', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createHeadlessStartupRuntime<
|
||||
{ mode: string },
|
||||
{ startAppLifecycle: (args: CliArgs) => void }
|
||||
>({
|
||||
appLifecycleRuntimeRunnerMainDeps: {
|
||||
app: { on: () => {} } as never,
|
||||
platform: 'darwin',
|
||||
shouldStartApp: () => true,
|
||||
parseArgs: () => ({}) as never,
|
||||
handleCliCommand: () => {},
|
||||
printHelp: () => {},
|
||||
logNoRunningInstance: () => {},
|
||||
onReady: async () => {},
|
||||
onWillQuitCleanup: () => {},
|
||||
shouldRestoreWindowsOnActivate: () => false,
|
||||
restoreWindowsOnActivate: () => {},
|
||||
shouldQuitOnWindowAllClosed: () => false,
|
||||
},
|
||||
bootstrap: {
|
||||
argv: ['node', 'main.js'],
|
||||
parseArgs: () => ({ command: 'start' }) as never,
|
||||
setLogLevel: (_level: string, _source: LogLevelSource) => {},
|
||||
forceX11Backend: () => {},
|
||||
enforceUnsupportedWaylandMode: () => {},
|
||||
shouldStartApp: () => true,
|
||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
configDir: '/tmp/config',
|
||||
defaultConfig: {} as never,
|
||||
generateConfigTemplate: () => 'template',
|
||||
generateDefaultConfigFile: async () => 0,
|
||||
setExitCode: () => {},
|
||||
quitApp: () => {},
|
||||
logGenerateConfigError: () => {},
|
||||
startAppLifecycle: (args: CliArgs) => {
|
||||
calls.push(`bootstrap:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
},
|
||||
createAppLifecycleRuntimeRunner: () => (args: CliArgs) => {
|
||||
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => ({
|
||||
startAppLifecycle: deps.startAppLifecycle,
|
||||
}),
|
||||
runStartupBootstrapRuntime: (deps) => {
|
||||
deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs);
|
||||
return { mode: 'started' };
|
||||
},
|
||||
applyStartupState: (state: { mode: string }) => {
|
||||
calls.push(`apply:${state.mode}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof runtime.appLifecycleRuntimeRunner, 'function');
|
||||
assert.equal(typeof runtime.runAndApplyStartupState, 'function');
|
||||
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
|
||||
assert.deepEqual(calls, ['lifecycle:start', 'apply:started']);
|
||||
});
|
||||
|
||||
test('headless startup runtime accepts grouped app lifecycle input', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createHeadlessStartupRuntime<
|
||||
{ mode: string },
|
||||
{ startAppLifecycle: (args: CliArgs) => void }
|
||||
>({
|
||||
appLifecycle: {
|
||||
app: { on: () => {} } as never,
|
||||
platform: 'darwin',
|
||||
shouldStartApp: () => true,
|
||||
parseArgs: () => ({}) as never,
|
||||
handleCliCommand: () => {},
|
||||
printHelp: () => {},
|
||||
logNoRunningInstance: () => {},
|
||||
onReady: async () => {},
|
||||
onWillQuitCleanup: () => {},
|
||||
shouldRestoreWindowsOnActivate: () => false,
|
||||
restoreWindowsOnActivate: () => {},
|
||||
shouldQuitOnWindowAllClosed: () => false,
|
||||
},
|
||||
bootstrap: {
|
||||
argv: ['node', 'main.js'],
|
||||
parseArgs: () => ({ command: 'start' }) as never,
|
||||
setLogLevel: (_level: string, _source: LogLevelSource) => {},
|
||||
forceX11Backend: () => {},
|
||||
enforceUnsupportedWaylandMode: () => {},
|
||||
shouldStartApp: () => true,
|
||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
configDir: '/tmp/config',
|
||||
defaultConfig: {} as never,
|
||||
generateConfigTemplate: () => 'template',
|
||||
generateDefaultConfigFile: async () => 0,
|
||||
setExitCode: () => {},
|
||||
quitApp: () => {},
|
||||
logGenerateConfigError: () => {},
|
||||
startAppLifecycle: (args: CliArgs) => {
|
||||
calls.push(`bootstrap:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
},
|
||||
createAppLifecycleRuntimeRunner: () => (args: CliArgs) => {
|
||||
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) => ({
|
||||
startAppLifecycle: deps.startAppLifecycle,
|
||||
}),
|
||||
runStartupBootstrapRuntime: (deps) => {
|
||||
deps.startAppLifecycle({ command: 'start' } as unknown as CliArgs);
|
||||
return { mode: 'started' };
|
||||
},
|
||||
applyStartupState: (state: { mode: string }) => {
|
||||
calls.push(`apply:${state.mode}`);
|
||||
},
|
||||
});
|
||||
|
||||
runtime.appLifecycleRuntimeRunner({ command: 'start' } as unknown as CliArgs);
|
||||
|
||||
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
|
||||
assert.deepEqual(calls, ['lifecycle:start', 'lifecycle:start', 'apply:started']);
|
||||
});
|
||||
106
src/main/headless-startup-runtime.ts
Normal file
106
src/main/headless-startup-runtime.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import type { LogLevelSource } from '../logger';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import type { StartupBootstrapRuntimeDeps } from '../core/services/startup';
|
||||
import { createAppLifecycleDepsRuntime, startAppLifecycle } from '../core/services/app-lifecycle';
|
||||
import type { AppLifecycleDepsRuntimeOptions } from '../core/services/app-lifecycle';
|
||||
import type { AppLifecycleRuntimeRunnerParams } from './startup-lifecycle';
|
||||
import type { StartupBootstrapRuntimeFactoryDeps } from './startup';
|
||||
import { createStartupBootstrapRuntimeDeps } from './startup';
|
||||
import { composeHeadlessStartupHandlers } from './runtime/composers/headless-startup-composer';
|
||||
import { createAppLifecycleRuntimeDeps } from './app-lifecycle';
|
||||
import { createBuildAppLifecycleRuntimeRunnerMainDepsHandler } from './runtime/startup-lifecycle-main-deps';
|
||||
|
||||
export interface HeadlessStartupBootstrapInput {
|
||||
argv: string[];
|
||||
parseArgs: (argv: string[]) => CliArgs;
|
||||
setLogLevel: (level: string, source: LogLevelSource) => void;
|
||||
forceX11Backend: (args: CliArgs) => void;
|
||||
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
|
||||
shouldStartApp: (args: CliArgs) => boolean;
|
||||
getDefaultSocketPath: () => string;
|
||||
defaultTexthookerPort: number;
|
||||
configDir: string;
|
||||
defaultConfig: ResolvedConfig;
|
||||
generateConfigTemplate: (config: ResolvedConfig) => string;
|
||||
generateDefaultConfigFile: (
|
||||
args: CliArgs,
|
||||
options: {
|
||||
configDir: string;
|
||||
defaultConfig: unknown;
|
||||
generateTemplate: (config: unknown) => string;
|
||||
},
|
||||
) => Promise<number>;
|
||||
setExitCode: (code: number) => void;
|
||||
quitApp: () => void;
|
||||
logGenerateConfigError: (message: string) => void;
|
||||
startAppLifecycle: (args: CliArgs) => void;
|
||||
}
|
||||
|
||||
export type HeadlessStartupAppLifecycleInput = AppLifecycleRuntimeRunnerParams;
|
||||
|
||||
export interface HeadlessStartupRuntimeInput<
|
||||
TStartupState,
|
||||
TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps,
|
||||
> {
|
||||
appLifecycleRuntimeRunnerMainDeps?: AppLifecycleDepsRuntimeOptions;
|
||||
appLifecycle?: HeadlessStartupAppLifecycleInput;
|
||||
bootstrap: HeadlessStartupBootstrapInput;
|
||||
createAppLifecycleRuntimeRunner?: (
|
||||
params: AppLifecycleDepsRuntimeOptions,
|
||||
) => (args: CliArgs) => void;
|
||||
createStartupBootstrapRuntimeDeps?: (
|
||||
deps: StartupBootstrapRuntimeFactoryDeps,
|
||||
) => TStartupBootstrapRuntimeDeps;
|
||||
runStartupBootstrapRuntime: (deps: TStartupBootstrapRuntimeDeps) => TStartupState;
|
||||
applyStartupState: (startupState: TStartupState) => void;
|
||||
}
|
||||
|
||||
export interface HeadlessStartupRuntime<TStartupState> {
|
||||
appLifecycleRuntimeRunner: (args: CliArgs) => void;
|
||||
runAndApplyStartupState: () => TStartupState;
|
||||
}
|
||||
|
||||
export function createHeadlessStartupRuntime<
|
||||
TStartupState,
|
||||
TStartupBootstrapRuntimeDeps = StartupBootstrapRuntimeDeps,
|
||||
>(
|
||||
input: HeadlessStartupRuntimeInput<TStartupState, TStartupBootstrapRuntimeDeps>,
|
||||
): HeadlessStartupRuntime<TStartupState> {
|
||||
const appLifecycleRuntimeRunnerMainDeps =
|
||||
input.appLifecycleRuntimeRunnerMainDeps ?? input.appLifecycle;
|
||||
|
||||
if (!appLifecycleRuntimeRunnerMainDeps) {
|
||||
throw new Error('Headless startup runtime needs app lifecycle runtime runner deps');
|
||||
}
|
||||
|
||||
const { appLifecycleRuntimeRunner, runAndApplyStartupState } = composeHeadlessStartupHandlers({
|
||||
startupRuntimeHandlersDeps: {
|
||||
appLifecycleRuntimeRunnerMainDeps: createBuildAppLifecycleRuntimeRunnerMainDepsHandler(
|
||||
appLifecycleRuntimeRunnerMainDeps,
|
||||
)(),
|
||||
createAppLifecycleRuntimeRunner:
|
||||
input.createAppLifecycleRuntimeRunner ??
|
||||
((params: AppLifecycleDepsRuntimeOptions) => (args: CliArgs) =>
|
||||
startAppLifecycle(
|
||||
args,
|
||||
createAppLifecycleDepsRuntime(createAppLifecycleRuntimeDeps(params)),
|
||||
)),
|
||||
buildStartupBootstrapMainDeps: (startAppLifecycle: (args: CliArgs) => void) => ({
|
||||
...input.bootstrap,
|
||||
startAppLifecycle,
|
||||
}),
|
||||
createStartupBootstrapRuntimeDeps: (deps: StartupBootstrapRuntimeFactoryDeps) =>
|
||||
input.createStartupBootstrapRuntimeDeps
|
||||
? input.createStartupBootstrapRuntimeDeps(deps)
|
||||
: (createStartupBootstrapRuntimeDeps(deps) as unknown as TStartupBootstrapRuntimeDeps),
|
||||
runStartupBootstrapRuntime: input.runStartupBootstrapRuntime,
|
||||
applyStartupState: input.applyStartupState,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
appLifecycleRuntimeRunner,
|
||||
runAndApplyStartupState,
|
||||
};
|
||||
}
|
||||
258
src/main/ipc-runtime-bootstrap.ts
Normal file
258
src/main/ipc-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { AnkiIntegration } from '../anki-integration';
|
||||
import type {
|
||||
JimakuApiResponse,
|
||||
JimakuLanguagePreference,
|
||||
KikuFieldGroupingChoice,
|
||||
ResolvedConfig,
|
||||
SubsyncManualRunRequest,
|
||||
SubsyncResult,
|
||||
} from '../types';
|
||||
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from '../jimaku/utils';
|
||||
import { applyRuntimeOptionResultRuntime } from '../core/services/runtime-options-ipc';
|
||||
import {
|
||||
playNextSubtitleRuntime,
|
||||
replayCurrentSubtitleRuntime,
|
||||
sendMpvCommandRuntime,
|
||||
} from '../core/services';
|
||||
import type { ConfigService } from '../config';
|
||||
import { applyControllerConfigUpdate } from './controller-config-update.js';
|
||||
import type { AnilistRuntime } from './anilist-runtime';
|
||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
||||
import { createIpcRuntimeFromMainState, type IpcRuntime } from './ipc-runtime';
|
||||
import type { MiningRuntime } from './mining-runtime';
|
||||
import type { MpvRuntime } from './mpv-runtime';
|
||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
||||
import type { OverlayUiRuntime } from './overlay-ui-runtime';
|
||||
import type { AppState } from './state';
|
||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
||||
import type { PlaylistBrowserIpcRuntime } from './runtime/playlist-browser-ipc';
|
||||
import type { YoutubeRuntime } from './youtube-runtime';
|
||||
import { resolveSubtitleStyleForRenderer } from './runtime/domains/overlay';
|
||||
import type { ShortcutsRuntime } from './shortcuts-runtime';
|
||||
|
||||
type OverlayManagerLike = {
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
};
|
||||
|
||||
type OverlayUiLike = Pick<
|
||||
OverlayUiRuntime<BrowserWindow>,
|
||||
| 'broadcastRuntimeOptionsChanged'
|
||||
| 'handleOverlayModalClosed'
|
||||
| 'openRuntimeOptionsPalette'
|
||||
| 'toggleVisibleOverlay'
|
||||
>;
|
||||
|
||||
type OverlayContentMeasurementStoreLike = {
|
||||
report: (payload: unknown) => void;
|
||||
};
|
||||
|
||||
type ConfigDerivedRuntimeLike = {
|
||||
jimakuFetchJson: <T>(
|
||||
endpoint: string,
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => Promise<JimakuApiResponse<T>>;
|
||||
getJimakuMaxEntryResults: () => number;
|
||||
getJimakuLanguagePreference: () => JimakuLanguagePreference;
|
||||
resolveJimakuApiKey: () => Promise<string | null>;
|
||||
};
|
||||
|
||||
type SubsyncRuntimeLike = {
|
||||
triggerFromConfig: () => Promise<void>;
|
||||
runManualFromIpc: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
};
|
||||
|
||||
export interface IpcRuntimeBootstrapInput {
|
||||
appState: AppState;
|
||||
userDataPath: string;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
configService: Pick<ConfigService, 'getRawConfig' | 'patchRawConfig'>;
|
||||
overlay: {
|
||||
manager: OverlayManagerLike;
|
||||
getOverlayUi: () => OverlayUiLike | undefined;
|
||||
modalRuntime: Pick<OverlayModalRuntime, 'notifyOverlayModalOpened'>;
|
||||
contentMeasurementStore: OverlayContentMeasurementStoreLike;
|
||||
};
|
||||
subtitle: SubtitleRuntime;
|
||||
mpvRuntime: Pick<MpvRuntime, 'shiftSubtitleDelayToAdjacentCue' | 'showMpvOsd'>;
|
||||
shortcuts: Pick<ShortcutsRuntime, 'getConfiguredShortcuts'>;
|
||||
actions: {
|
||||
requestAppQuit: () => void;
|
||||
openYomitanSettings: () => boolean;
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
||||
};
|
||||
runtimes: {
|
||||
youtube: Pick<YoutubeRuntime, 'openYoutubeTrackPickerFromPlayback' | 'resolveActivePicker'>;
|
||||
anilist: Pick<
|
||||
AnilistRuntime,
|
||||
| 'getStatusSnapshot'
|
||||
| 'clearTokenState'
|
||||
| 'openAnilistSetupWindow'
|
||||
| 'getQueueStatusSnapshot'
|
||||
| 'processNextAnilistRetryUpdate'
|
||||
>;
|
||||
mining: Pick<MiningRuntime, 'appendClipboardVideoToQueue'>;
|
||||
dictionarySupport: Pick<
|
||||
DictionarySupportRuntime,
|
||||
| 'createFieldGroupingCallback'
|
||||
| 'getFieldGroupingResolver'
|
||||
| 'setFieldGroupingResolver'
|
||||
| 'resolveMediaPathForJimaku'
|
||||
>;
|
||||
playlistBrowser: Pick<PlaylistBrowserIpcRuntime, 'playlistBrowserMainDeps'>;
|
||||
configDerived: ConfigDerivedRuntimeLike;
|
||||
subsync: SubsyncRuntimeLike;
|
||||
};
|
||||
}
|
||||
|
||||
export function createIpcRuntimeBootstrap(input: IpcRuntimeBootstrapInput): IpcRuntime {
|
||||
return createIpcRuntimeFromMainState({
|
||||
mpv: {
|
||||
mainDeps: {
|
||||
triggerSubsyncFromConfig: () => input.runtimes.subsync.triggerFromConfig(),
|
||||
openRuntimeOptionsPalette: () => input.overlay.getOverlayUi()?.openRuntimeOptionsPalette(),
|
||||
openYoutubeTrackPicker: () => input.runtimes.youtube.openYoutubeTrackPickerFromPlayback(),
|
||||
openPlaylistBrowser: () => input.actions.openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => {
|
||||
if (!input.appState.runtimeOptionsManager) {
|
||||
return { ok: false, error: 'Runtime options manager unavailable' };
|
||||
}
|
||||
return applyRuntimeOptionResultRuntime(
|
||||
input.appState.runtimeOptionsManager.cycleOption(id, direction),
|
||||
(text) => input.mpvRuntime.showMpvOsd(text),
|
||||
);
|
||||
},
|
||||
showMpvOsd: (text) => input.mpvRuntime.showMpvOsd(text),
|
||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(input.appState.mpvClient),
|
||||
playNextSubtitle: () => playNextSubtitleRuntime(input.appState.mpvClient),
|
||||
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||
input.mpvRuntime.shiftSubtitleDelayToAdjacentCue(direction),
|
||||
sendMpvCommand: (rawCommand) => sendMpvCommandRuntime(input.appState.mpvClient, rawCommand),
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
isMpvConnected: () =>
|
||||
Boolean(input.appState.mpvClient && input.appState.mpvClient.connected),
|
||||
hasRuntimeOptionsManager: () => input.appState.runtimeOptionsManager !== null,
|
||||
},
|
||||
runSubsyncManualFromIpc: (request) => input.runtimes.subsync.runManualFromIpc(request),
|
||||
},
|
||||
runtimeOptions: {
|
||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
||||
showMpvOsd: (text) => input.mpvRuntime.showMpvOsd(text),
|
||||
},
|
||||
main: {
|
||||
window: {
|
||||
getMainWindow: () => input.overlay.manager.getMainWindow(),
|
||||
getVisibleOverlayVisibility: () => input.overlay.manager.getVisibleOverlayVisible(),
|
||||
focusMainWindow: () => {
|
||||
const mainWindow = input.overlay.manager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
if (!mainWindow.isFocused()) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
},
|
||||
onOverlayModalClosed: (modal) =>
|
||||
input.overlay.getOverlayUi()?.handleOverlayModalClosed(modal),
|
||||
onOverlayModalOpened: (modal) => {
|
||||
input.overlay.modalRuntime.notifyOverlayModalOpened(modal);
|
||||
},
|
||||
onYoutubePickerResolve: (request) => input.runtimes.youtube.resolveActivePicker(request),
|
||||
openYomitanSettings: () => input.actions.openYomitanSettings(),
|
||||
quitApp: () => input.actions.requestAppQuit(),
|
||||
toggleVisibleOverlay: () => input.overlay.getOverlayUi()?.toggleVisibleOverlay(),
|
||||
},
|
||||
subtitle: {
|
||||
tokenizeCurrentSubtitle: async () => await input.subtitle.tokenizeCurrentSubtitle(),
|
||||
getCurrentSubtitleRaw: () => input.appState.currentSubText,
|
||||
getCurrentSubtitleAss: () => input.appState.currentSubAssText,
|
||||
getSubtitleSidebarSnapshot: async () => await input.subtitle.getSubtitleSidebarSnapshot(),
|
||||
getPlaybackPaused: () => input.appState.playbackPaused,
|
||||
getSubtitlePosition: () => input.subtitle.loadSubtitlePosition(),
|
||||
getSubtitleStyle: () => resolveSubtitleStyleForRenderer(input.getResolvedConfig()),
|
||||
saveSubtitlePosition: (position) => input.subtitle.saveSubtitlePosition(position),
|
||||
getMecabTokenizer: () => input.appState.mecabTokenizer,
|
||||
getKeybindings: () => input.appState.keybindings,
|
||||
getConfiguredShortcuts: () => input.shortcuts.getConfiguredShortcuts(),
|
||||
getStatsToggleKey: () => input.getResolvedConfig().stats.toggleKey,
|
||||
getMarkWatchedKey: () => input.getResolvedConfig().stats.markWatchedKey,
|
||||
getSecondarySubMode: () => input.appState.secondarySubMode,
|
||||
},
|
||||
controller: {
|
||||
getControllerConfig: () => input.getResolvedConfig().controller,
|
||||
saveControllerConfig: (update) => {
|
||||
const currentRawConfig = input.configService.getRawConfig();
|
||||
input.configService.patchRawConfig({
|
||||
controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
|
||||
});
|
||||
},
|
||||
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
|
||||
input.configService.patchRawConfig({
|
||||
controller: {
|
||||
preferredGamepadId,
|
||||
preferredGamepadLabel,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
getAnkiConnectStatus: () => input.appState.ankiIntegration !== null,
|
||||
getRuntimeOptions: () => input.appState.runtimeOptionsManager?.listOptions() ?? [],
|
||||
reportOverlayContentBounds: (payload) => {
|
||||
input.overlay.contentMeasurementStore.report(payload);
|
||||
},
|
||||
getImmersionTracker: () => input.appState.immersionTracker,
|
||||
},
|
||||
playlistBrowser: input.runtimes.playlistBrowser.playlistBrowserMainDeps,
|
||||
anilist: {
|
||||
getStatus: () => input.runtimes.anilist.getStatusSnapshot(),
|
||||
clearToken: () => input.runtimes.anilist.clearTokenState(),
|
||||
openSetup: () => input.runtimes.anilist.openAnilistSetupWindow(),
|
||||
getQueueStatus: () => input.runtimes.anilist.getQueueStatusSnapshot(),
|
||||
retryQueueNow: () => input.runtimes.anilist.processNextAnilistRetryUpdate(),
|
||||
},
|
||||
mining: {
|
||||
appendClipboardVideoToQueue: () => input.runtimes.mining.appendClipboardVideoToQueue(),
|
||||
},
|
||||
},
|
||||
ankiJimaku: {
|
||||
patchAnkiConnectEnabled: (enabled) => {
|
||||
input.configService.patchRawConfig({ ankiConnect: { enabled } });
|
||||
},
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
||||
getSubtitleTimingTracker: () => input.appState.subtitleTimingTracker,
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
getAnkiIntegration: () => input.appState.ankiIntegration,
|
||||
setAnkiIntegration: (integration) => input.actions.setAnkiIntegration(integration),
|
||||
getKnownWordCacheStatePath: () => path.join(input.userDataPath, 'known-words-cache.json'),
|
||||
showDesktopNotification: input.actions.showDesktopNotification,
|
||||
createFieldGroupingCallback: () =>
|
||||
input.runtimes.dictionarySupport.createFieldGroupingCallback(),
|
||||
broadcastRuntimeOptionsChanged: () =>
|
||||
input.overlay.getOverlayUi()?.broadcastRuntimeOptionsChanged(),
|
||||
getFieldGroupingResolver: () => input.runtimes.dictionarySupport.getFieldGroupingResolver(),
|
||||
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) =>
|
||||
input.runtimes.dictionarySupport.setFieldGroupingResolver(resolver),
|
||||
parseMediaInfo: (mediaPath: string | null) =>
|
||||
parseMediaInfo(input.runtimes.dictionarySupport.resolveMediaPathForJimaku(mediaPath)),
|
||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
||||
jimakuFetchJson: <T>(
|
||||
endpoint: string,
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
): Promise<JimakuApiResponse<T>> =>
|
||||
input.runtimes.configDerived.jimakuFetchJson<T>(endpoint, query),
|
||||
getJimakuMaxEntryResults: () => input.runtimes.configDerived.getJimakuMaxEntryResults(),
|
||||
getJimakuLanguagePreference: () => input.runtimes.configDerived.getJimakuLanguagePreference(),
|
||||
resolveJimakuApiKey: () => input.runtimes.configDerived.resolveJimakuApiKey(),
|
||||
isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath),
|
||||
downloadToFile: (url: string, destPath: string, headers: Record<string, string>) =>
|
||||
downloadToFile(url, destPath, headers),
|
||||
},
|
||||
});
|
||||
}
|
||||
43
src/main/ipc-runtime-services.ts
Normal file
43
src/main/ipc-runtime-services.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
createIpcDepsRuntime,
|
||||
registerAnkiJimakuIpcRuntime,
|
||||
registerIpcHandlers,
|
||||
} from '../core/services';
|
||||
import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc';
|
||||
import {
|
||||
createAnkiJimakuIpcRuntimeServiceDeps,
|
||||
createMainIpcRuntimeServiceDeps,
|
||||
createRuntimeOptionsIpcDeps,
|
||||
} from './dependencies';
|
||||
import type {
|
||||
AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
MainIpcRuntimeServiceDepsParams,
|
||||
} from './dependencies';
|
||||
import type { RegisterIpcRuntimeServicesParams } from './ipc-runtime';
|
||||
|
||||
export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void {
|
||||
registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params)));
|
||||
}
|
||||
|
||||
export function registerAnkiJimakuIpcRuntimeServices(
|
||||
params: AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
): void {
|
||||
registerAnkiJimakuIpcRuntime(
|
||||
createAnkiJimakuIpcRuntimeServiceDeps(params),
|
||||
registerAnkiJimakuIpcHandlers,
|
||||
);
|
||||
}
|
||||
|
||||
export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void {
|
||||
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
|
||||
getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager,
|
||||
showMpvOsd: params.runtimeOptions.showMpvOsd,
|
||||
});
|
||||
|
||||
registerMainIpcRuntimeServices({
|
||||
...params.mainDeps,
|
||||
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
|
||||
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
|
||||
});
|
||||
registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps);
|
||||
}
|
||||
196
src/main/ipc-runtime.test.ts
Normal file
196
src/main/ipc-runtime.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createIpcRuntime, createIpcRuntimeFromMainState } from './ipc-runtime';
|
||||
|
||||
function createBaseRuntimeInput(capturedRegistration: { value: unknown | null }) {
|
||||
const manualResult = { ok: true, summary: 'done' };
|
||||
const main = {
|
||||
window: {
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
focusMainWindow: () => {},
|
||||
onOverlayModalClosed: () => {},
|
||||
onOverlayModalOpened: () => {},
|
||||
onYoutubePickerResolve: async () => ({ ok: true }) as never,
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
},
|
||||
playlistBrowser: {
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: null,
|
||||
directoryAvailable: false,
|
||||
directoryStatus: '',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: null,
|
||||
currentFilePath: null,
|
||||
}),
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
subtitle: {
|
||||
tokenizeCurrentSubtitle: async () => null,
|
||||
getCurrentSubtitleRaw: () => '',
|
||||
getCurrentSubtitleAss: () => '',
|
||||
getSubtitleSidebarSnapshot: async () => null as never,
|
||||
getPlaybackPaused: () => false,
|
||||
getSubtitlePosition: () => null,
|
||||
getSubtitleStyle: () => null,
|
||||
saveSubtitlePosition: () => {},
|
||||
getMecabTokenizer: () => null,
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => null,
|
||||
getStatsToggleKey: () => '',
|
||||
getMarkWatchedKey: () => '',
|
||||
getSecondarySubMode: () => 'hover',
|
||||
},
|
||||
controller: {
|
||||
getControllerConfig: () => ({}) as never,
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
},
|
||||
runtime: {
|
||||
getMpvClient: () => null,
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => [],
|
||||
reportOverlayContentBounds: () => {},
|
||||
getImmersionTracker: () => null,
|
||||
},
|
||||
anilist: {
|
||||
getStatus: () => null,
|
||||
clearToken: () => {},
|
||||
openSetup: () => {},
|
||||
getQueueStatus: () => null,
|
||||
retryQueueNow: async () => ({ ok: true, message: 'ok' }) as never,
|
||||
},
|
||||
mining: {
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
};
|
||||
const ankiJimaku = {
|
||||
patchAnkiConnectEnabled: () => {},
|
||||
getResolvedConfig: () => ({}),
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getMpvClient: () => null,
|
||||
getAnkiIntegration: () => null,
|
||||
setAnkiIntegration: () => {},
|
||||
getKnownWordCacheStatePath: () => '/tmp/known-words.json',
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => async () => ({}) as never,
|
||||
broadcastRuntimeOptionsChanged: () => {},
|
||||
getFieldGroupingResolver: () => null,
|
||||
setFieldGroupingResolver: () => {},
|
||||
parseMediaInfo: () => ({}) as never,
|
||||
getCurrentMediaPath: () => null,
|
||||
jimakuFetchJson: async () => ({ data: null, error: null }) as never,
|
||||
getJimakuMaxEntryResults: () => 5,
|
||||
getJimakuLanguagePreference: () => 'ja' as const,
|
||||
resolveJimakuApiKey: async () => null,
|
||||
isRemoteMediaPath: () => false,
|
||||
downloadToFile: async () => ({ ok: true, path: '/tmp/file' }) as never,
|
||||
};
|
||||
|
||||
return {
|
||||
mpv: {
|
||||
mainDeps: {
|
||||
triggerSubsyncFromConfig: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
openPlaylistBrowser: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||
sendMpvCommand: () => {},
|
||||
getMpvClient: () => null,
|
||||
isMpvConnected: () => true,
|
||||
hasRuntimeOptionsManager: () => true,
|
||||
},
|
||||
handleMpvCommandFromIpcRuntime: () => {},
|
||||
runSubsyncManualFromIpc: async () => manualResult as never,
|
||||
},
|
||||
main,
|
||||
ankiJimaku,
|
||||
registration: {
|
||||
runtimeOptions: {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
showMpvOsd: () => {},
|
||||
},
|
||||
main,
|
||||
ankiJimaku,
|
||||
registerIpcRuntimeServices: (params: unknown) => {
|
||||
capturedRegistration.value = params;
|
||||
},
|
||||
},
|
||||
registerIpcRuntimeServices: (params: unknown) => {
|
||||
capturedRegistration.value = params;
|
||||
},
|
||||
manualResult,
|
||||
};
|
||||
}
|
||||
|
||||
test('ipc runtime registers composed IPC handlers from explicit registration input', async () => {
|
||||
const capturedRegistration = { value: null as unknown | null };
|
||||
const input = createBaseRuntimeInput(capturedRegistration);
|
||||
|
||||
const runtime = createIpcRuntime({
|
||||
mpv: input.mpv,
|
||||
registration: input.registration,
|
||||
});
|
||||
|
||||
runtime.registerIpcRuntimeHandlers();
|
||||
|
||||
assert.ok(capturedRegistration.value);
|
||||
const registration = capturedRegistration.value as {
|
||||
runtimeOptions: { showMpvOsd: unknown };
|
||||
mainDeps: {
|
||||
getPlaylistBrowserSnapshot: unknown;
|
||||
handleMpvCommand: unknown;
|
||||
runSubsyncManual: (payload: unknown) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
assert.equal(registration.runtimeOptions.showMpvOsd !== undefined, true);
|
||||
assert.equal(registration.mainDeps.getPlaylistBrowserSnapshot instanceof Function, true);
|
||||
assert.equal(registration.mainDeps.handleMpvCommand instanceof Function, true);
|
||||
assert.deepEqual(
|
||||
await registration.mainDeps.runSubsyncManual({ payload: null } as never),
|
||||
input.manualResult,
|
||||
);
|
||||
});
|
||||
|
||||
test('ipc runtime builds grouped registration input from main state', async () => {
|
||||
const capturedRegistration = { value: null as unknown | null };
|
||||
const input = createBaseRuntimeInput(capturedRegistration);
|
||||
|
||||
const runtime = createIpcRuntimeFromMainState({
|
||||
mpv: input.mpv,
|
||||
runtimeOptions: input.registration.runtimeOptions,
|
||||
main: input.main,
|
||||
ankiJimaku: input.ankiJimaku,
|
||||
});
|
||||
|
||||
runtime.registerIpcRuntimeHandlers();
|
||||
|
||||
assert.ok(capturedRegistration.value);
|
||||
const registration = capturedRegistration.value as {
|
||||
runtimeOptions: { showMpvOsd: unknown };
|
||||
mainDeps: {
|
||||
getPlaylistBrowserSnapshot: unknown;
|
||||
handleMpvCommand: unknown;
|
||||
runSubsyncManual: (payload: unknown) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
assert.equal(registration.runtimeOptions.showMpvOsd !== undefined, true);
|
||||
assert.equal(registration.mainDeps.getPlaylistBrowserSnapshot instanceof Function, true);
|
||||
assert.equal(registration.mainDeps.handleMpvCommand instanceof Function, true);
|
||||
assert.deepEqual(
|
||||
await registration.mainDeps.runSubsyncManual({ payload: null } as never),
|
||||
input.manualResult,
|
||||
);
|
||||
});
|
||||
@@ -1,17 +1,15 @@
|
||||
import {
|
||||
createIpcDepsRuntime,
|
||||
registerAnkiJimakuIpcRuntime,
|
||||
registerIpcHandlers,
|
||||
} from '../core/services';
|
||||
import { registerAnkiJimakuIpcHandlers } from '../core/services/anki-jimaku-ipc';
|
||||
import {
|
||||
createAnkiJimakuIpcRuntimeServiceDeps,
|
||||
import type {
|
||||
AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
createMainIpcRuntimeServiceDeps,
|
||||
MainIpcRuntimeServiceDepsParams,
|
||||
createRuntimeOptionsIpcDeps,
|
||||
RuntimeOptionsIpcDepsParams,
|
||||
} from './dependencies';
|
||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './dependencies';
|
||||
import {
|
||||
handleMpvCommandFromIpcRuntime,
|
||||
type MpvCommandFromIpcRuntimeDeps,
|
||||
} from './ipc-mpv-command';
|
||||
import { registerIpcRuntimeServices } from './ipc-runtime-services';
|
||||
import { composeIpcRuntimeHandlers } from './runtime/composers/ipc-runtime-composer';
|
||||
|
||||
export interface RegisterIpcRuntimeServicesParams {
|
||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
||||
@@ -19,28 +17,140 @@ export interface RegisterIpcRuntimeServicesParams {
|
||||
ankiJimakuDeps: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||
}
|
||||
|
||||
export function registerMainIpcRuntimeServices(params: MainIpcRuntimeServiceDepsParams): void {
|
||||
registerIpcHandlers(createIpcDepsRuntime(createMainIpcRuntimeServiceDeps(params)));
|
||||
export interface IpcRuntimeMainInput {
|
||||
window: Pick<
|
||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||
| 'getMainWindow'
|
||||
| 'getVisibleOverlayVisibility'
|
||||
| 'focusMainWindow'
|
||||
| 'onOverlayModalClosed'
|
||||
| 'onOverlayModalOpened'
|
||||
| 'onYoutubePickerResolve'
|
||||
| 'openYomitanSettings'
|
||||
| 'quitApp'
|
||||
| 'toggleVisibleOverlay'
|
||||
>;
|
||||
playlistBrowser: Pick<
|
||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||
| 'getPlaylistBrowserSnapshot'
|
||||
| 'appendPlaylistBrowserFile'
|
||||
| 'playPlaylistBrowserIndex'
|
||||
| 'removePlaylistBrowserIndex'
|
||||
| 'movePlaylistBrowserIndex'
|
||||
>;
|
||||
subtitle: Pick<
|
||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||
| 'tokenizeCurrentSubtitle'
|
||||
| 'getCurrentSubtitleRaw'
|
||||
| 'getCurrentSubtitleAss'
|
||||
| 'getSubtitleSidebarSnapshot'
|
||||
| 'getPlaybackPaused'
|
||||
| 'getSubtitlePosition'
|
||||
| 'getSubtitleStyle'
|
||||
| 'saveSubtitlePosition'
|
||||
| 'getMecabTokenizer'
|
||||
| 'getKeybindings'
|
||||
| 'getConfiguredShortcuts'
|
||||
| 'getStatsToggleKey'
|
||||
| 'getMarkWatchedKey'
|
||||
| 'getSecondarySubMode'
|
||||
>;
|
||||
controller: Pick<
|
||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||
'getControllerConfig' | 'saveControllerConfig' | 'saveControllerPreference'
|
||||
>;
|
||||
runtime: Pick<
|
||||
RegisterIpcRuntimeServicesParams['mainDeps'],
|
||||
'getMpvClient' | 'getAnkiConnectStatus' | 'getRuntimeOptions' | 'reportOverlayContentBounds'
|
||||
> &
|
||||
Partial<Pick<RegisterIpcRuntimeServicesParams['mainDeps'], 'getImmersionTracker'>>;
|
||||
anilist: {
|
||||
getStatus: RegisterIpcRuntimeServicesParams['mainDeps']['getAnilistStatus'];
|
||||
clearToken: RegisterIpcRuntimeServicesParams['mainDeps']['clearAnilistToken'];
|
||||
openSetup: RegisterIpcRuntimeServicesParams['mainDeps']['openAnilistSetup'];
|
||||
getQueueStatus: RegisterIpcRuntimeServicesParams['mainDeps']['getAnilistQueueStatus'];
|
||||
retryQueueNow: RegisterIpcRuntimeServicesParams['mainDeps']['retryAnilistQueueNow'];
|
||||
};
|
||||
mining: {
|
||||
appendClipboardVideoToQueue: RegisterIpcRuntimeServicesParams['mainDeps']['appendClipboardVideoToQueue'];
|
||||
};
|
||||
}
|
||||
|
||||
export function registerAnkiJimakuIpcRuntimeServices(
|
||||
params: AnkiJimakuIpcRuntimeServiceDepsParams,
|
||||
): void {
|
||||
registerAnkiJimakuIpcRuntime(
|
||||
createAnkiJimakuIpcRuntimeServiceDeps(params),
|
||||
registerAnkiJimakuIpcHandlers,
|
||||
);
|
||||
export interface IpcRuntimeRegistrationInput {
|
||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
||||
main: IpcRuntimeMainInput;
|
||||
ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||
registerIpcRuntimeServices: (params: RegisterIpcRuntimeServicesParams) => void;
|
||||
}
|
||||
|
||||
export function registerIpcRuntimeServices(params: RegisterIpcRuntimeServicesParams): void {
|
||||
const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
|
||||
getRuntimeOptionsManager: params.runtimeOptions.getRuntimeOptionsManager,
|
||||
showMpvOsd: params.runtimeOptions.showMpvOsd,
|
||||
});
|
||||
registerMainIpcRuntimeServices({
|
||||
...params.mainDeps,
|
||||
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
|
||||
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
|
||||
});
|
||||
registerAnkiJimakuIpcRuntimeServices(params.ankiJimakuDeps);
|
||||
export interface IpcRuntimeInput {
|
||||
mpv: {
|
||||
mainDeps: MpvCommandFromIpcRuntimeDeps;
|
||||
handleMpvCommandFromIpcRuntime: (
|
||||
command: (string | number)[],
|
||||
deps: MpvCommandFromIpcRuntimeDeps,
|
||||
) => void;
|
||||
runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual'];
|
||||
};
|
||||
registration: IpcRuntimeRegistrationInput;
|
||||
}
|
||||
|
||||
export interface IpcRuntime {
|
||||
registerIpcRuntimeHandlers: () => void;
|
||||
}
|
||||
|
||||
export interface IpcRuntimeFromMainStateInput {
|
||||
mpv: {
|
||||
mainDeps: MpvCommandFromIpcRuntimeDeps;
|
||||
runSubsyncManualFromIpc: MainIpcRuntimeServiceDepsParams['runSubsyncManual'];
|
||||
};
|
||||
runtimeOptions: RuntimeOptionsIpcDepsParams;
|
||||
main: IpcRuntimeMainInput;
|
||||
ankiJimaku: AnkiJimakuIpcRuntimeServiceDepsParams;
|
||||
}
|
||||
|
||||
export function createIpcRuntime(input: IpcRuntimeInput): IpcRuntime {
|
||||
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
mpvCommandMainDeps: input.mpv.mainDeps,
|
||||
handleMpvCommandFromIpcRuntime: input.mpv.handleMpvCommandFromIpcRuntime,
|
||||
runSubsyncManualFromIpc: input.mpv.runSubsyncManualFromIpc,
|
||||
registration: {
|
||||
runtimeOptions: input.registration.runtimeOptions,
|
||||
mainDeps: {
|
||||
...input.registration.main.window,
|
||||
...input.registration.main.playlistBrowser,
|
||||
...input.registration.main.subtitle,
|
||||
...input.registration.main.controller,
|
||||
...input.registration.main.runtime,
|
||||
getAnilistStatus: input.registration.main.anilist.getStatus,
|
||||
clearAnilistToken: input.registration.main.anilist.clearToken,
|
||||
openAnilistSetup: input.registration.main.anilist.openSetup,
|
||||
getAnilistQueueStatus: input.registration.main.anilist.getQueueStatus,
|
||||
retryAnilistQueueNow: input.registration.main.anilist.retryQueueNow,
|
||||
appendClipboardVideoToQueue: input.registration.main.mining.appendClipboardVideoToQueue,
|
||||
},
|
||||
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps(input.registration.ankiJimaku),
|
||||
registerIpcRuntimeServices: (params) => input.registration.registerIpcRuntimeServices(params),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
registerIpcRuntimeHandlers,
|
||||
};
|
||||
}
|
||||
|
||||
export function createIpcRuntimeFromMainState(input: IpcRuntimeFromMainStateInput): IpcRuntime {
|
||||
return createIpcRuntime({
|
||||
mpv: {
|
||||
mainDeps: input.mpv.mainDeps,
|
||||
handleMpvCommandFromIpcRuntime,
|
||||
runSubsyncManualFromIpc: input.mpv.runSubsyncManualFromIpc,
|
||||
},
|
||||
registration: {
|
||||
runtimeOptions: input.runtimeOptions,
|
||||
main: input.main,
|
||||
ankiJimaku: input.ankiJimaku,
|
||||
registerIpcRuntimeServices,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
160
src/main/jellyfin-runtime-coordinator.ts
Normal file
160
src/main/jellyfin-runtime-coordinator.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
import { DEFAULT_CONFIG } from '../config';
|
||||
import {
|
||||
JellyfinRemoteSessionService,
|
||||
authenticateWithPasswordRuntime,
|
||||
jellyfinTicksToSecondsRuntime,
|
||||
listJellyfinItemsRuntime,
|
||||
listJellyfinLibrariesRuntime,
|
||||
listJellyfinSubtitleTracksRuntime,
|
||||
resolveJellyfinPlaybackPlanRuntime,
|
||||
sendMpvCommandRuntime,
|
||||
} from '../core/services';
|
||||
import type { MpvIpcClient } from '../core/services/mpv';
|
||||
import type { JellyfinSetupWindowLike } from './jellyfin-runtime';
|
||||
import { createJellyfinRuntime } from './jellyfin-runtime';
|
||||
|
||||
export interface JellyfinRuntimeCoordinatorInput {
|
||||
getResolvedConfig: Parameters<typeof createJellyfinRuntime>[0]['getResolvedConfig'];
|
||||
configService: {
|
||||
patchRawConfig: Parameters<typeof createJellyfinRuntime>[0]['patchRawConfig'];
|
||||
};
|
||||
tokenStore: Parameters<typeof createJellyfinRuntime>[0]['tokenStore'];
|
||||
platform: NodeJS.Platform;
|
||||
execPath: string;
|
||||
defaultMpvLogPath: string;
|
||||
defaultMpvArgs: readonly string[];
|
||||
connectTimeoutMs: number;
|
||||
autoLaunchTimeoutMs: number;
|
||||
langPref: string;
|
||||
progressIntervalMs: number;
|
||||
ticksPerSecond: number;
|
||||
appState: {
|
||||
mpvSocketPath: string;
|
||||
mpvClient: MpvIpcClient | null;
|
||||
jellyfinSetupWindow: BrowserWindow | null;
|
||||
};
|
||||
actions: {
|
||||
createMpvClient: () => MpvIpcClient;
|
||||
applyJellyfinMpvDefaults: (client: MpvIpcClient) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
};
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, details?: unknown) => void;
|
||||
debug: (message: string, details?: unknown) => void;
|
||||
error: (message: string, error?: unknown) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function createJellyfinRuntimeCoordinator(input: JellyfinRuntimeCoordinatorInput) {
|
||||
return createJellyfinRuntime<JellyfinSetupWindowLike>({
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
getEnv: (name) => process.env[name],
|
||||
patchRawConfig: (patch) => {
|
||||
input.configService.patchRawConfig(patch);
|
||||
},
|
||||
defaultJellyfinConfig: DEFAULT_CONFIG.jellyfin,
|
||||
tokenStore: input.tokenStore,
|
||||
platform: input.platform,
|
||||
execPath: input.execPath,
|
||||
defaultMpvLogPath: input.defaultMpvLogPath,
|
||||
defaultMpvArgs: [...input.defaultMpvArgs],
|
||||
connectTimeoutMs: input.connectTimeoutMs,
|
||||
autoLaunchTimeoutMs: input.autoLaunchTimeoutMs,
|
||||
langPref: input.langPref,
|
||||
progressIntervalMs: input.progressIntervalMs,
|
||||
ticksPerSecond: input.ticksPerSecond,
|
||||
getMpvSocketPath: () => input.appState.mpvSocketPath,
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
setMpvClient: (client) => {
|
||||
input.appState.mpvClient = client as MpvIpcClient | null;
|
||||
},
|
||||
createMpvClient: () => input.actions.createMpvClient(),
|
||||
sendMpvCommand: (client, command) => sendMpvCommandRuntime(client as MpvIpcClient, command),
|
||||
applyJellyfinMpvDefaults: (client) =>
|
||||
input.actions.applyJellyfinMpvDefaults(client as MpvIpcClient),
|
||||
showMpvOsd: (message) => input.actions.showMpvOsd(message),
|
||||
removeSocketPath: (socketPath) => {
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
},
|
||||
spawnMpv: (args) =>
|
||||
spawn('mpv', args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
}),
|
||||
wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
|
||||
authenticateWithPassword: (serverUrl, username, password, clientInfo) =>
|
||||
authenticateWithPasswordRuntime(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
clientInfo as Parameters<typeof authenticateWithPasswordRuntime>[3],
|
||||
),
|
||||
listJellyfinLibraries: (session, clientInfo) =>
|
||||
listJellyfinLibrariesRuntime(
|
||||
session as Parameters<typeof listJellyfinLibrariesRuntime>[0],
|
||||
clientInfo as Parameters<typeof listJellyfinLibrariesRuntime>[1],
|
||||
),
|
||||
listJellyfinItems: (session, clientInfo, params) =>
|
||||
listJellyfinItemsRuntime(
|
||||
session as Parameters<typeof listJellyfinItemsRuntime>[0],
|
||||
clientInfo as Parameters<typeof listJellyfinItemsRuntime>[1],
|
||||
params as Parameters<typeof listJellyfinItemsRuntime>[2],
|
||||
),
|
||||
listJellyfinSubtitleTracks: (session, clientInfo, itemId) =>
|
||||
listJellyfinSubtitleTracksRuntime(
|
||||
session as Parameters<typeof listJellyfinSubtitleTracksRuntime>[0],
|
||||
clientInfo as Parameters<typeof listJellyfinSubtitleTracksRuntime>[1],
|
||||
itemId,
|
||||
),
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) => {
|
||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||
fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf-8');
|
||||
},
|
||||
resolvePlaybackPlan: (params) =>
|
||||
resolveJellyfinPlaybackPlanRuntime(
|
||||
(params as { session: Parameters<typeof resolveJellyfinPlaybackPlanRuntime>[0] }).session,
|
||||
(params as { clientInfo: Parameters<typeof resolveJellyfinPlaybackPlanRuntime>[1] })
|
||||
.clientInfo,
|
||||
(
|
||||
params as {
|
||||
jellyfinConfig: ReturnType<
|
||||
JellyfinRuntimeCoordinatorInput['getResolvedConfig']
|
||||
>['jellyfin'];
|
||||
}
|
||||
).jellyfinConfig,
|
||||
{
|
||||
itemId: (params as { itemId: string }).itemId,
|
||||
audioStreamIndex:
|
||||
(params as { audioStreamIndex?: number | null }).audioStreamIndex ?? undefined,
|
||||
subtitleStreamIndex:
|
||||
(params as { subtitleStreamIndex?: number | null }).subtitleStreamIndex ?? undefined,
|
||||
},
|
||||
),
|
||||
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
|
||||
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options as never),
|
||||
defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId,
|
||||
defaultClientName: DEFAULT_CONFIG.jellyfin.clientName,
|
||||
defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion,
|
||||
createBrowserWindow: (options) => {
|
||||
const window = new BrowserWindow(options);
|
||||
input.appState.jellyfinSetupWindow = window;
|
||||
window.on('closed', () => {
|
||||
input.appState.jellyfinSetupWindow = null;
|
||||
});
|
||||
return window as unknown as JellyfinSetupWindowLike;
|
||||
},
|
||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
||||
logInfo: (message) => input.logger.info(message),
|
||||
logWarn: (message, details) => input.logger.warn(message, details),
|
||||
logDebug: (message, details) => input.logger.debug(message, details),
|
||||
logError: (message, error) => input.logger.error(message, error),
|
||||
now: () => Date.now(),
|
||||
});
|
||||
}
|
||||
95
src/main/jellyfin-runtime.test.ts
Normal file
95
src/main/jellyfin-runtime.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { JellyfinRuntimeInput } from './jellyfin-runtime';
|
||||
import { createJellyfinRuntime } from './jellyfin-runtime';
|
||||
|
||||
test('jellyfin runtime reuses existing setup window', () => {
|
||||
const calls: string[] = [];
|
||||
let windowCount = 0;
|
||||
|
||||
const runtime = createJellyfinRuntime({
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
jellyfin: {
|
||||
enabled: true,
|
||||
serverUrl: 'https://media.example',
|
||||
username: 'demo',
|
||||
},
|
||||
}) as ReturnType<JellyfinRuntimeInput['getResolvedConfig']>,
|
||||
getEnv: () => undefined,
|
||||
patchRawConfig: () => {},
|
||||
defaultJellyfinConfig: {
|
||||
enabled: false,
|
||||
serverUrl: '',
|
||||
username: '',
|
||||
} as JellyfinRuntimeInput['defaultJellyfinConfig'],
|
||||
tokenStore: {
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
clearSession: () => {},
|
||||
},
|
||||
platform: 'linux',
|
||||
execPath: '/usr/bin/electron',
|
||||
defaultMpvLogPath: '/tmp/mpv.log',
|
||||
defaultMpvArgs: ['--idle=yes'],
|
||||
connectTimeoutMs: 1000,
|
||||
autoLaunchTimeoutMs: 1000,
|
||||
langPref: 'ja,en',
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||
getMpvClient: () => null,
|
||||
setMpvClient: () => {},
|
||||
createMpvClient: () => ({}),
|
||||
sendMpvCommand: () => {},
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showMpvOsd: () => {},
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: () => ({}),
|
||||
wait: async () => {},
|
||||
authenticateWithPassword: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
listJellyfinLibraries: async () => [],
|
||||
listJellyfinItems: async () => [],
|
||||
listJellyfinSubtitleTracks: async () => [],
|
||||
writeJellyfinPreviewAuth: () => {},
|
||||
resolvePlaybackPlan: async () => ({}),
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
createRemoteSessionService: () => ({}),
|
||||
defaultDeviceId: 'device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0.0',
|
||||
createBrowserWindow: () => {
|
||||
windowCount += 1;
|
||||
return {
|
||||
webContents: {
|
||||
on: () => {},
|
||||
},
|
||||
loadURL: () => {
|
||||
calls.push('loadURL');
|
||||
},
|
||||
on: () => {},
|
||||
focus: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
close: () => {},
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
},
|
||||
encodeURIComponent: (value) => encodeURIComponent(value),
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logDebug: () => {},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
runtime.openJellyfinSetupWindow();
|
||||
runtime.openJellyfinSetupWindow();
|
||||
|
||||
assert.equal(windowCount, 1);
|
||||
assert.deepEqual(calls, ['loadURL', 'focus']);
|
||||
assert.equal(runtime.getQuitOnDisconnectArmed(), false);
|
||||
assert.ok(runtime.getSetupWindow());
|
||||
});
|
||||
423
src/main/jellyfin-runtime.ts
Normal file
423
src/main/jellyfin-runtime.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import {
|
||||
buildJellyfinSetupFormHtml,
|
||||
getConfiguredJellyfinSession,
|
||||
parseJellyfinSetupSubmissionUrl,
|
||||
} from './runtime/domains/jellyfin';
|
||||
import {
|
||||
composeJellyfinRuntimeHandlers,
|
||||
type JellyfinRuntimeComposerOptions,
|
||||
} from './runtime/composers/jellyfin-runtime-composer';
|
||||
import { createCreateJellyfinSetupWindowHandler } from './runtime/setup-window-factory';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: extract each dep-block's type from the composer options.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Deps<K extends keyof JellyfinRuntimeComposerOptions> = JellyfinRuntimeComposerOptions[K];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolved-config shape (extracted from composer).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ResolvedConfigShape =
|
||||
Deps<'getResolvedJellyfinConfigMainDeps'> extends {
|
||||
getResolvedConfig: () => infer R;
|
||||
}
|
||||
? R
|
||||
: never;
|
||||
type JellyfinConfigShape = ResolvedConfigShape extends { jellyfin: infer J } ? J : never;
|
||||
|
||||
/** Stored-session shape (what the token store persists). */
|
||||
type StoredSessionShape = { accessToken: string; userId: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface JellyfinSessionStoreLike {
|
||||
loadSession: () => StoredSessionShape | null | undefined;
|
||||
saveSession: (session: StoredSessionShape) => void;
|
||||
clearSession: () => void;
|
||||
}
|
||||
|
||||
export interface JellyfinSetupWindowLike {
|
||||
webContents: {
|
||||
on: (event: 'will-navigate', handler: (event: unknown, url: string) => void) => void;
|
||||
};
|
||||
loadURL: (url: string) => Promise<void> | void;
|
||||
on: (event: 'closed', handler: () => void) => void;
|
||||
focus: () => void;
|
||||
close: () => void;
|
||||
isDestroyed: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for createJellyfinRuntime.
|
||||
*
|
||||
* Fields whose types vary across handler files (MpvClient, Session, ClientInfo,
|
||||
* RemoteSessionService, etc.) are typed as `unknown`. The factory body bridges
|
||||
* these to the handler-specific structural types via per-dep-block type
|
||||
* annotations (`Deps<K>`) with targeted `as` casts on the individual
|
||||
* function references. This keeps the public-facing input surface simple and
|
||||
* avoids 7+ generic type parameters that previously required `as never` casts.
|
||||
*/
|
||||
export interface JellyfinRuntimeInput<
|
||||
TSetupWindow extends JellyfinSetupWindowLike = JellyfinSetupWindowLike,
|
||||
> {
|
||||
getResolvedConfig: () => ResolvedConfigShape;
|
||||
getEnv: (name: string) => string | undefined;
|
||||
patchRawConfig: (patch: unknown) => void;
|
||||
defaultJellyfinConfig: JellyfinConfigShape;
|
||||
tokenStore: JellyfinSessionStoreLike;
|
||||
platform: NodeJS.Platform;
|
||||
execPath: string;
|
||||
defaultMpvLogPath: string;
|
||||
defaultMpvArgs: readonly string[];
|
||||
connectTimeoutMs: number;
|
||||
autoLaunchTimeoutMs: number;
|
||||
langPref: string;
|
||||
progressIntervalMs: number;
|
||||
ticksPerSecond: number;
|
||||
getMpvSocketPath: () => string;
|
||||
getMpvClient: () => unknown;
|
||||
setMpvClient: (client: unknown) => void;
|
||||
createMpvClient: () => unknown;
|
||||
sendMpvCommand: (client: unknown, command: Array<string | number>) => void;
|
||||
applyJellyfinMpvDefaults: (client: unknown) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
removeSocketPath: (socketPath: string) => void;
|
||||
spawnMpv: (args: string[]) => unknown;
|
||||
wait: (delayMs: number) => Promise<void>;
|
||||
authenticateWithPassword: (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
clientInfo: unknown,
|
||||
) => Promise<unknown>;
|
||||
listJellyfinLibraries: (session: unknown, clientInfo: unknown) => Promise<unknown>;
|
||||
listJellyfinItems: (session: unknown, clientInfo: unknown, params: unknown) => Promise<unknown>;
|
||||
listJellyfinSubtitleTracks: (
|
||||
session: unknown,
|
||||
clientInfo: unknown,
|
||||
itemId: string,
|
||||
) => Promise<unknown>;
|
||||
writeJellyfinPreviewAuth: (responsePath: string, payload: unknown) => void;
|
||||
resolvePlaybackPlan: (params: unknown) => Promise<unknown>;
|
||||
convertTicksToSeconds: (ticks: number) => number;
|
||||
createRemoteSessionService: (options: unknown) => unknown;
|
||||
defaultDeviceId: string;
|
||||
defaultClientName: string;
|
||||
defaultClientVersion: string;
|
||||
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TSetupWindow;
|
||||
encodeURIComponent: (value: string) => string;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
logDebug: (message: string, details?: unknown) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
now?: () => number;
|
||||
schedule?: (callback: () => void, delayMs: number) => void;
|
||||
}
|
||||
|
||||
export interface JellyfinRuntime<
|
||||
TSetupWindow extends JellyfinSetupWindowLike = JellyfinSetupWindowLike,
|
||||
> {
|
||||
getResolvedJellyfinConfig: () => JellyfinConfigShape;
|
||||
reportJellyfinRemoteProgress: (forceImmediate?: boolean) => Promise<void>;
|
||||
reportJellyfinRemoteStopped: () => Promise<void>;
|
||||
startJellyfinRemoteSession: () => Promise<void>;
|
||||
stopJellyfinRemoteSession: () => Promise<void>;
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
getQuitOnDisconnectArmed: () => boolean;
|
||||
clearQuitOnDisconnectArm: () => void;
|
||||
getRemoteSession: () => unknown;
|
||||
getSetupWindow: () => TSetupWindow | null;
|
||||
}
|
||||
|
||||
export function createJellyfinRuntime<TSetupWindow extends JellyfinSetupWindowLike>(
|
||||
input: JellyfinRuntimeInput<TSetupWindow>,
|
||||
): JellyfinRuntime<TSetupWindow> {
|
||||
const now = input.now ?? Date.now;
|
||||
const schedule =
|
||||
input.schedule ??
|
||||
((callback: () => void, delayMs: number) => {
|
||||
setTimeout(callback, delayMs);
|
||||
});
|
||||
|
||||
let playQuitOnDisconnectArmed = false;
|
||||
let activePlayback: unknown = null;
|
||||
let lastProgressAtMs = 0;
|
||||
let mpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
||||
let remoteSession: unknown = null;
|
||||
let setupWindow: TSetupWindow | null = null;
|
||||
|
||||
// Each dep block is typed with Deps<K> so TypeScript verifies structural
|
||||
// compatibility with the composer. The `as Deps<K>[field]` casts on
|
||||
// function references bridge `unknown`-typed input methods to the
|
||||
// handler-specific structural types. This replaces 23 `as never` casts
|
||||
// with targeted, auditable type assertions.
|
||||
|
||||
const getResolvedJellyfinConfigMainDeps: Deps<'getResolvedJellyfinConfigMainDeps'> = {
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
loadStoredSession: () => input.tokenStore.loadSession(),
|
||||
getEnv: (name) => input.getEnv(name),
|
||||
};
|
||||
|
||||
const getJellyfinClientInfoMainDeps: Deps<'getJellyfinClientInfoMainDeps'> = {
|
||||
getResolvedJellyfinConfig: () => input.getResolvedConfig().jellyfin,
|
||||
getDefaultJellyfinConfig: () => input.defaultJellyfinConfig,
|
||||
};
|
||||
|
||||
const waitForMpvConnectedMainDeps: Deps<'waitForMpvConnectedMainDeps'> = {
|
||||
getMpvClient: input.getMpvClient as Deps<'waitForMpvConnectedMainDeps'>['getMpvClient'],
|
||||
now,
|
||||
sleep: (delayMs) => input.wait(delayMs),
|
||||
};
|
||||
|
||||
const launchMpvIdleForJellyfinPlaybackMainDeps: Deps<'launchMpvIdleForJellyfinPlaybackMainDeps'> =
|
||||
{
|
||||
getSocketPath: () => input.getMpvSocketPath(),
|
||||
platform: input.platform,
|
||||
execPath: input.execPath,
|
||||
defaultMpvLogPath: input.defaultMpvLogPath,
|
||||
defaultMpvArgs: input.defaultMpvArgs,
|
||||
removeSocketPath: (socketPath) => input.removeSocketPath(socketPath),
|
||||
spawnMpv: input.spawnMpv as Deps<'launchMpvIdleForJellyfinPlaybackMainDeps'>['spawnMpv'],
|
||||
logWarn: (message, error) => input.logWarn(message, error),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
};
|
||||
|
||||
const ensureMpvConnectedForJellyfinPlaybackMainDeps: Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'> =
|
||||
{
|
||||
getMpvClient:
|
||||
input.getMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['getMpvClient'],
|
||||
setMpvClient:
|
||||
input.setMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['setMpvClient'],
|
||||
createMpvClient:
|
||||
input.createMpvClient as Deps<'ensureMpvConnectedForJellyfinPlaybackMainDeps'>['createMpvClient'],
|
||||
getAutoLaunchInFlight: () => mpvAutoLaunchInFlight,
|
||||
setAutoLaunchInFlight: (promise) => {
|
||||
mpvAutoLaunchInFlight = promise;
|
||||
},
|
||||
connectTimeoutMs: input.connectTimeoutMs,
|
||||
autoLaunchTimeoutMs: input.autoLaunchTimeoutMs,
|
||||
};
|
||||
|
||||
const preloadJellyfinExternalSubtitlesMainDeps: Deps<'preloadJellyfinExternalSubtitlesMainDeps'> =
|
||||
{
|
||||
listJellyfinSubtitleTracks:
|
||||
input.listJellyfinSubtitleTracks as Deps<'preloadJellyfinExternalSubtitlesMainDeps'>['listJellyfinSubtitleTracks'],
|
||||
getMpvClient:
|
||||
input.getMpvClient as Deps<'preloadJellyfinExternalSubtitlesMainDeps'>['getMpvClient'],
|
||||
sendMpvCommand: (command) => input.sendMpvCommand(input.getMpvClient(), command),
|
||||
wait: (delayMs) => input.wait(delayMs),
|
||||
logDebug: (message, error) => input.logDebug(message, error),
|
||||
};
|
||||
|
||||
const playJellyfinItemInMpvMainDeps: Deps<'playJellyfinItemInMpvMainDeps'> = {
|
||||
getMpvClient: input.getMpvClient as Deps<'playJellyfinItemInMpvMainDeps'>['getMpvClient'],
|
||||
resolvePlaybackPlan:
|
||||
input.resolvePlaybackPlan as Deps<'playJellyfinItemInMpvMainDeps'>['resolvePlaybackPlan'],
|
||||
applyJellyfinMpvDefaults:
|
||||
input.applyJellyfinMpvDefaults as Deps<'playJellyfinItemInMpvMainDeps'>['applyJellyfinMpvDefaults'],
|
||||
sendMpvCommand: (command) => input.sendMpvCommand(input.getMpvClient(), command),
|
||||
armQuitOnDisconnect: () => {
|
||||
playQuitOnDisconnectArmed = false;
|
||||
schedule(() => {
|
||||
playQuitOnDisconnectArmed = true;
|
||||
}, 3000);
|
||||
},
|
||||
schedule: (callback, delayMs) => {
|
||||
schedule(callback, delayMs);
|
||||
},
|
||||
convertTicksToSeconds: (ticks) => input.convertTicksToSeconds(ticks),
|
||||
setActivePlayback: (state) => {
|
||||
activePlayback = state;
|
||||
},
|
||||
setLastProgressAtMs: (value) => {
|
||||
lastProgressAtMs = value;
|
||||
},
|
||||
reportPlaying: (payload) => {
|
||||
const session = remoteSession as { reportPlaying?: (payload: unknown) => unknown } | null;
|
||||
if (typeof session?.reportPlaying === 'function') {
|
||||
void session.reportPlaying(payload);
|
||||
}
|
||||
},
|
||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
||||
};
|
||||
|
||||
const remoteComposerBase: Omit<Deps<'remoteComposerOptions'>, 'getConfiguredSession'> = {
|
||||
logWarn: (message) => input.logWarn(message),
|
||||
getMpvClient: input.getMpvClient as Deps<'remoteComposerOptions'>['getMpvClient'],
|
||||
sendMpvCommand: input.sendMpvCommand as Deps<'remoteComposerOptions'>['sendMpvCommand'],
|
||||
jellyfinTicksToSeconds: (ticks) => input.convertTicksToSeconds(ticks),
|
||||
getActivePlayback: () =>
|
||||
activePlayback as ReturnType<Deps<'remoteComposerOptions'>['getActivePlayback']>,
|
||||
clearActivePlayback: () => {
|
||||
activePlayback = null;
|
||||
},
|
||||
getSession: () => remoteSession as ReturnType<Deps<'remoteComposerOptions'>['getSession']>,
|
||||
getNow: now,
|
||||
getLastProgressAtMs: () => lastProgressAtMs,
|
||||
setLastProgressAtMs: (value) => {
|
||||
lastProgressAtMs = value;
|
||||
},
|
||||
progressIntervalMs: input.progressIntervalMs,
|
||||
ticksPerSecond: input.ticksPerSecond,
|
||||
logDebug: (message, error) => input.logDebug(message, error),
|
||||
};
|
||||
|
||||
const handleJellyfinAuthCommandsMainDeps: Deps<'handleJellyfinAuthCommandsMainDeps'> = {
|
||||
patchRawConfig: (patch) => input.patchRawConfig(patch),
|
||||
authenticateWithPassword:
|
||||
input.authenticateWithPassword as Deps<'handleJellyfinAuthCommandsMainDeps'>['authenticateWithPassword'],
|
||||
saveStoredSession: (session) => input.tokenStore.saveSession(session as StoredSessionShape),
|
||||
clearStoredSession: () => input.tokenStore.clearSession(),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
};
|
||||
|
||||
const handleJellyfinListCommandsMainDeps: Deps<'handleJellyfinListCommandsMainDeps'> = {
|
||||
listJellyfinLibraries:
|
||||
input.listJellyfinLibraries as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinLibraries'],
|
||||
listJellyfinItems:
|
||||
input.listJellyfinItems as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinItems'],
|
||||
listJellyfinSubtitleTracks:
|
||||
input.listJellyfinSubtitleTracks as Deps<'handleJellyfinListCommandsMainDeps'>['listJellyfinSubtitleTracks'],
|
||||
writeJellyfinPreviewAuth: (responsePath, payload) =>
|
||||
input.writeJellyfinPreviewAuth(responsePath, payload),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
};
|
||||
|
||||
const handleJellyfinPlayCommandMainDeps: Deps<'handleJellyfinPlayCommandMainDeps'> = {
|
||||
logWarn: (message) => input.logWarn(message),
|
||||
};
|
||||
|
||||
const handleJellyfinRemoteAnnounceCommandMainDeps: Deps<'handleJellyfinRemoteAnnounceCommandMainDeps'> =
|
||||
{
|
||||
getRemoteSession: () =>
|
||||
remoteSession as ReturnType<
|
||||
Deps<'handleJellyfinRemoteAnnounceCommandMainDeps'>['getRemoteSession']
|
||||
>,
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logWarn: (message) => input.logWarn(message),
|
||||
};
|
||||
|
||||
const startJellyfinRemoteSessionMainDeps: Deps<'startJellyfinRemoteSessionMainDeps'> = {
|
||||
getCurrentSession: () =>
|
||||
remoteSession as ReturnType<Deps<'startJellyfinRemoteSessionMainDeps'>['getCurrentSession']>,
|
||||
setCurrentSession: (session) => {
|
||||
remoteSession = session;
|
||||
},
|
||||
createRemoteSessionService:
|
||||
input.createRemoteSessionService as Deps<'startJellyfinRemoteSessionMainDeps'>['createRemoteSessionService'],
|
||||
defaultDeviceId: input.defaultDeviceId,
|
||||
defaultClientName: input.defaultClientName,
|
||||
defaultClientVersion: input.defaultClientVersion,
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logWarn: (message, details) => input.logWarn(message, details),
|
||||
};
|
||||
|
||||
const stopJellyfinRemoteSessionMainDeps: Deps<'stopJellyfinRemoteSessionMainDeps'> = {
|
||||
getCurrentSession: () =>
|
||||
remoteSession as ReturnType<Deps<'stopJellyfinRemoteSessionMainDeps'>['getCurrentSession']>,
|
||||
setCurrentSession: (session) => {
|
||||
remoteSession = session;
|
||||
},
|
||||
clearActivePlayback: () => {
|
||||
activePlayback = null;
|
||||
},
|
||||
};
|
||||
|
||||
const runJellyfinCommandMainDeps: Deps<'runJellyfinCommandMainDeps'> = {
|
||||
defaultServerUrl: input.defaultJellyfinConfig.serverUrl,
|
||||
};
|
||||
|
||||
const maybeFocusExistingJellyfinSetupWindowMainDeps: Deps<'maybeFocusExistingJellyfinSetupWindowMainDeps'> =
|
||||
{
|
||||
getSetupWindow: () => setupWindow,
|
||||
};
|
||||
|
||||
const openJellyfinSetupWindowMainDeps: Deps<'openJellyfinSetupWindowMainDeps'> = {
|
||||
createSetupWindow: createCreateJellyfinSetupWindowHandler({
|
||||
createBrowserWindow: (options) => input.createBrowserWindow(options),
|
||||
}),
|
||||
buildSetupFormHtml: (defaultServer, defaultUser) =>
|
||||
buildJellyfinSetupFormHtml(defaultServer, defaultUser),
|
||||
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
|
||||
authenticateWithPassword:
|
||||
input.authenticateWithPassword as Deps<'openJellyfinSetupWindowMainDeps'>['authenticateWithPassword'],
|
||||
saveStoredSession: (session) => input.tokenStore.saveSession(session as StoredSessionShape),
|
||||
patchJellyfinConfig: (session) => {
|
||||
const jellyfinSession = session as { serverUrl?: string; username?: string };
|
||||
input.patchRawConfig({
|
||||
jellyfin: {
|
||||
enabled: true,
|
||||
serverUrl: jellyfinSession.serverUrl,
|
||||
username: jellyfinSession.username,
|
||||
},
|
||||
});
|
||||
},
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logError: (message, error) => input.logError(message, error),
|
||||
showMpvOsd: (message) => input.showMpvOsd(message),
|
||||
clearSetupWindow: () => {
|
||||
setupWindow = null;
|
||||
},
|
||||
setSetupWindow: (window) => {
|
||||
setupWindow = window as TSetupWindow | null;
|
||||
},
|
||||
encodeURIComponent: (value) => input.encodeURIComponent(value),
|
||||
};
|
||||
|
||||
const runtime = composeJellyfinRuntimeHandlers({
|
||||
getResolvedJellyfinConfigMainDeps,
|
||||
getJellyfinClientInfoMainDeps,
|
||||
waitForMpvConnectedMainDeps,
|
||||
launchMpvIdleForJellyfinPlaybackMainDeps,
|
||||
ensureMpvConnectedForJellyfinPlaybackMainDeps,
|
||||
preloadJellyfinExternalSubtitlesMainDeps,
|
||||
playJellyfinItemInMpvMainDeps,
|
||||
remoteComposerOptions: {
|
||||
...remoteComposerBase,
|
||||
getConfiguredSession: () => getConfiguredJellyfinSession(runtime.getResolvedJellyfinConfig()),
|
||||
},
|
||||
handleJellyfinAuthCommandsMainDeps,
|
||||
handleJellyfinListCommandsMainDeps,
|
||||
handleJellyfinPlayCommandMainDeps,
|
||||
handleJellyfinRemoteAnnounceCommandMainDeps,
|
||||
startJellyfinRemoteSessionMainDeps,
|
||||
stopJellyfinRemoteSessionMainDeps,
|
||||
runJellyfinCommandMainDeps,
|
||||
maybeFocusExistingJellyfinSetupWindowMainDeps,
|
||||
openJellyfinSetupWindowMainDeps,
|
||||
});
|
||||
|
||||
return {
|
||||
getResolvedJellyfinConfig: () => runtime.getResolvedJellyfinConfig(),
|
||||
reportJellyfinRemoteProgress: async (forceImmediate) => {
|
||||
await runtime.reportJellyfinRemoteProgress(forceImmediate);
|
||||
},
|
||||
reportJellyfinRemoteStopped: async () => {
|
||||
await runtime.reportJellyfinRemoteStopped();
|
||||
},
|
||||
startJellyfinRemoteSession: async () => {
|
||||
await runtime.startJellyfinRemoteSession();
|
||||
},
|
||||
stopJellyfinRemoteSession: async () => {
|
||||
await runtime.stopJellyfinRemoteSession();
|
||||
},
|
||||
runJellyfinCommand: async (args) => {
|
||||
await runtime.runJellyfinCommand(args);
|
||||
},
|
||||
openJellyfinSetupWindow: () => {
|
||||
runtime.openJellyfinSetupWindow();
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => playQuitOnDisconnectArmed,
|
||||
clearQuitOnDisconnectArm: () => {
|
||||
playQuitOnDisconnectArmed = false;
|
||||
},
|
||||
getRemoteSession: () => remoteSession,
|
||||
getSetupWindow: () => setupWindow,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as path from 'path';
|
||||
import type { JlptLevel } from '../types';
|
||||
|
||||
import { createJlptVocabularyLookup } from '../core/services';
|
||||
import { createJlptVocabularyLookup } from '../core/services/jlpt-vocab';
|
||||
|
||||
export interface JlptDictionarySearchPathDeps {
|
||||
getDictionaryRoots: () => string[];
|
||||
|
||||
169
src/main/main-boot-runtime.ts
Normal file
169
src/main/main-boot-runtime.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import { createAnilistTokenStore } from '../core/services/anilist/anilist-token-store';
|
||||
import { createJellyfinTokenStore } from '../core/services/jellyfin-token-store';
|
||||
import { createAnilistUpdateQueue } from '../core/services/anilist/anilist-update-queue';
|
||||
import {
|
||||
SubtitleWebSocket,
|
||||
createOverlayContentMeasurementStore,
|
||||
createOverlayManager,
|
||||
} from '../core/services';
|
||||
import { ConfigService } from '../config';
|
||||
import { resolveConfigDir } from '../config/path-resolution';
|
||||
import { createAppState } from './state';
|
||||
import {
|
||||
createMainBootServices,
|
||||
type AppLifecycleShape,
|
||||
type MainBootServicesResult,
|
||||
} from './boot/services';
|
||||
import { createLogger } from '../logger';
|
||||
import { createMainRuntimeRegistry } from './runtime/registry';
|
||||
import { createOverlayModalInputState } from './runtime/overlay-modal-input-state';
|
||||
import { createOverlayModalRuntimeService } from './overlay-runtime';
|
||||
import { buildConfigParseErrorDetails, failStartupFromConfig } from './config-validation';
|
||||
import {
|
||||
registerSecondInstanceHandlerEarly,
|
||||
requestSingleInstanceLockEarly,
|
||||
shouldBypassSingleInstanceLockForArgv,
|
||||
} from './early-single-instance';
|
||||
import {
|
||||
createBuildOverlayContentMeasurementStoreMainDepsHandler,
|
||||
createBuildOverlayModalRuntimeMainDepsHandler,
|
||||
} from './runtime/domains/overlay';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
export type MainBootRuntime = MainBootServicesResult<
|
||||
ConfigService,
|
||||
ReturnType<typeof createAnilistTokenStore>,
|
||||
ReturnType<typeof createJellyfinTokenStore>,
|
||||
ReturnType<typeof createAnilistUpdateQueue>,
|
||||
SubtitleWebSocket,
|
||||
ReturnType<typeof createLogger>,
|
||||
ReturnType<typeof createMainRuntimeRegistry>,
|
||||
ReturnType<typeof createOverlayManager>,
|
||||
ReturnType<typeof createOverlayModalInputState>,
|
||||
ReturnType<typeof createOverlayContentMeasurementStore>,
|
||||
ReturnType<typeof createOverlayModalRuntimeService>,
|
||||
ReturnType<typeof createAppState>,
|
||||
AppLifecycleShape
|
||||
>;
|
||||
|
||||
export interface MainBootRuntimeInput {
|
||||
platform: NodeJS.Platform;
|
||||
argv: string[];
|
||||
appDataDir: string | undefined;
|
||||
xdgConfigHome: string | undefined;
|
||||
homeDir: string;
|
||||
defaultMpvLogFile: string;
|
||||
envMpvLog: string | undefined;
|
||||
defaultTexthookerPort: number;
|
||||
getDefaultSocketPath: () => string;
|
||||
app: {
|
||||
setPath: (name: string, value: string) => void;
|
||||
quit: () => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
|
||||
on: Function;
|
||||
whenReady: () => Promise<void>;
|
||||
};
|
||||
dialog: {
|
||||
showErrorBox: (title: string, details: string) => void;
|
||||
};
|
||||
overlay: {
|
||||
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void;
|
||||
getSyncOverlayVisibilityForModal: () => () => void;
|
||||
createModalWindow: () => BrowserWindow;
|
||||
getOverlayGeometry: () => WindowGeometry;
|
||||
};
|
||||
notifications: {
|
||||
notifyAnilistTokenStoreWarning: (message: string) => void;
|
||||
requestAppQuit: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function createMainBootRuntime(input: MainBootRuntimeInput): MainBootRuntime {
|
||||
return createMainBootServices({
|
||||
platform: input.platform,
|
||||
argv: input.argv,
|
||||
appDataDir: input.appDataDir,
|
||||
xdgConfigHome: input.xdgConfigHome,
|
||||
homeDir: input.homeDir,
|
||||
defaultMpvLogFile: input.defaultMpvLogFile,
|
||||
envMpvLog: input.envMpvLog,
|
||||
defaultTexthookerPort: input.defaultTexthookerPort,
|
||||
getDefaultSocketPath: () => input.getDefaultSocketPath(),
|
||||
resolveConfigDir,
|
||||
existsSync: (targetPath) => fs.existsSync(targetPath),
|
||||
mkdirSync: (targetPath, options) => {
|
||||
fs.mkdirSync(targetPath, options);
|
||||
},
|
||||
joinPath: (...parts) => path.join(...parts),
|
||||
app: input.app,
|
||||
shouldBypassSingleInstanceLock: () => shouldBypassSingleInstanceLockForArgv(input.argv),
|
||||
requestSingleInstanceLockEarly: () => requestSingleInstanceLockEarly(input.app as never),
|
||||
registerSecondInstanceHandlerEarly: (listener) => {
|
||||
registerSecondInstanceHandlerEarly(input.app as never, listener);
|
||||
},
|
||||
onConfigStartupParseError: (error) => {
|
||||
failStartupFromConfig(
|
||||
'SubMiner config parse error',
|
||||
buildConfigParseErrorDetails(error.path, error.parseError),
|
||||
{
|
||||
logError: (details) => console.error(details),
|
||||
showErrorBox: (title, details) => input.dialog.showErrorBox(title, details),
|
||||
quit: () => input.notifications.requestAppQuit(),
|
||||
},
|
||||
);
|
||||
},
|
||||
createConfigService: (configDir) => new ConfigService(configDir),
|
||||
createAnilistTokenStore: (targetPath) =>
|
||||
createAnilistTokenStore(targetPath, {
|
||||
info: (message: string) => console.info(message),
|
||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||
error: (message: string, details?: unknown) => console.error(message, details),
|
||||
warnUser: (message: string) => input.notifications.notifyAnilistTokenStoreWarning(message),
|
||||
}),
|
||||
createJellyfinTokenStore: (targetPath) =>
|
||||
createJellyfinTokenStore(targetPath, {
|
||||
info: (message: string) => console.info(message),
|
||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||
error: (message: string, details?: unknown) => console.error(message, details),
|
||||
}),
|
||||
createAnilistUpdateQueue: (targetPath) =>
|
||||
createAnilistUpdateQueue(targetPath, {
|
||||
info: (message: string) => console.info(message),
|
||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||
error: (message: string, details?: unknown) => console.error(message, details),
|
||||
}),
|
||||
createSubtitleWebSocket: () => new SubtitleWebSocket(),
|
||||
createLogger,
|
||||
createMainRuntimeRegistry,
|
||||
createOverlayManager,
|
||||
createOverlayModalInputState,
|
||||
createOverlayContentMeasurementStore: ({ logger }) =>
|
||||
createOverlayContentMeasurementStore(
|
||||
createBuildOverlayContentMeasurementStoreMainDepsHandler({
|
||||
now: () => Date.now(),
|
||||
warn: (message: string) => logger.warn(message),
|
||||
})(),
|
||||
),
|
||||
getSyncOverlayShortcutsForModal: () => input.overlay.getSyncOverlayShortcutsForModal(),
|
||||
getSyncOverlayVisibilityForModal: () => input.overlay.getSyncOverlayVisibilityForModal(),
|
||||
createOverlayModalRuntime: ({ overlayManager, overlayModalInputState }) =>
|
||||
createOverlayModalRuntimeService(
|
||||
createBuildOverlayModalRuntimeMainDepsHandler({
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getModalWindow: () => overlayManager.getModalWindow(),
|
||||
createModalWindow: () => input.overlay.createModalWindow(),
|
||||
getModalGeometry: () => input.overlay.getOverlayGeometry(),
|
||||
setModalWindowBounds: (geometry) => overlayManager.setModalWindowBounds(geometry),
|
||||
})(),
|
||||
{
|
||||
onModalStateChange: (isActive: boolean) =>
|
||||
overlayModalInputState.handleModalInputStateChange(isActive),
|
||||
},
|
||||
),
|
||||
createAppState,
|
||||
}) as MainBootRuntime;
|
||||
}
|
||||
114
src/main/main-boot-services-bootstrap.test.ts
Normal file
114
src/main/main-boot-services-bootstrap.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createMainBootServicesBootstrap } from './main-boot-services-bootstrap';
|
||||
|
||||
test('main boot services bootstrap composes grouped inputs into boot services', () => {
|
||||
const calls: string[] = [];
|
||||
const modalWindow = {} as never;
|
||||
const overlayManager = {
|
||||
getModalWindow: () => modalWindow,
|
||||
};
|
||||
type AppStateStub = {
|
||||
kind: 'app-state';
|
||||
input: {
|
||||
mpvSocketPath: string;
|
||||
texthookerPort: number;
|
||||
};
|
||||
};
|
||||
type OverlayModalRuntimeStub = {
|
||||
kind: 'overlay-modal-runtime';
|
||||
};
|
||||
|
||||
const overlayModalInputState = {
|
||||
kind: 'overlay-modal-input-state',
|
||||
handleModalInputStateChange: (isActive: boolean) => {
|
||||
calls.push(`modal-state:${String(isActive)}`);
|
||||
},
|
||||
};
|
||||
const overlayModalInputStateParams: {
|
||||
getModalWindow: () => unknown;
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
}[] = [];
|
||||
const createOverlayModalInputState = (params: (typeof overlayModalInputStateParams)[number]) => {
|
||||
overlayModalInputStateParams.push(params);
|
||||
return overlayModalInputState as never;
|
||||
};
|
||||
|
||||
const createOverlayModalRuntime = (params: {
|
||||
onModalStateChange: (isActive: boolean) => void;
|
||||
}) => {
|
||||
calls.push(`modal:${String(params.onModalStateChange(true))}`);
|
||||
return { kind: 'overlay-modal-runtime' } as OverlayModalRuntimeStub;
|
||||
};
|
||||
|
||||
const boot = createMainBootServicesBootstrap({
|
||||
system: {
|
||||
platform: 'darwin',
|
||||
argv: ['node', 'main.js'],
|
||||
appDataDir: '/tmp/app-data',
|
||||
xdgConfigHome: '/tmp/xdg',
|
||||
homeDir: '/Users/test',
|
||||
defaultMpvLogFile: '/tmp/mpv.log',
|
||||
envMpvLog: '',
|
||||
defaultTexthookerPort: 5174,
|
||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
||||
resolveConfigDir: () => '/tmp/config',
|
||||
existsSync: () => true,
|
||||
mkdirSync: () => undefined,
|
||||
joinPath: (...parts: string[]) => path.posix.join(...parts),
|
||||
app: {
|
||||
setPath: () => undefined,
|
||||
quit: () => undefined,
|
||||
on: () => undefined,
|
||||
whenReady: async () => undefined,
|
||||
},
|
||||
},
|
||||
singleInstance: {
|
||||
shouldBypassSingleInstanceLock: () => false,
|
||||
requestSingleInstanceLockEarly: () => true,
|
||||
registerSecondInstanceHandlerEarly: () => undefined,
|
||||
onConfigStartupParseError: () => undefined,
|
||||
},
|
||||
factories: {
|
||||
createConfigService: () => ({ kind: 'config-service' }) as never,
|
||||
createAnilistTokenStore: () => ({ kind: 'anilist-token-store' }) as never,
|
||||
createJellyfinTokenStore: () => ({ kind: 'jellyfin-token-store' }) as never,
|
||||
createAnilistUpdateQueue: () => ({ kind: 'anilist-update-queue' }) as never,
|
||||
createSubtitleWebSocket: () => ({ kind: 'subtitle-websocket' }) as never,
|
||||
createLogger: () =>
|
||||
({
|
||||
warn: () => undefined,
|
||||
info: () => undefined,
|
||||
error: () => undefined,
|
||||
}) as never,
|
||||
createMainRuntimeRegistry: () => ({ kind: 'runtime-registry' }) as never,
|
||||
createOverlayManager: () => overlayManager as never,
|
||||
createOverlayModalInputState,
|
||||
createOverlayContentMeasurementStore: () => ({ kind: 'overlay-content-store' }) as never,
|
||||
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => {
|
||||
calls.push(`shortcuts:${String(isActive)}`);
|
||||
},
|
||||
getSyncOverlayVisibilityForModal: () => () => {
|
||||
calls.push('visibility');
|
||||
},
|
||||
createOverlayModalRuntime,
|
||||
createAppState: (input) => ({ kind: 'app-state', input }) satisfies AppStateStub,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(boot.configDir, '/tmp/config');
|
||||
assert.equal(boot.userDataPath, '/tmp/config');
|
||||
assert.equal(boot.defaultImmersionDbPath, '/tmp/config/immersion.sqlite');
|
||||
assert.equal(boot.appState.input.mpvSocketPath, '/tmp/mpv.sock');
|
||||
assert.equal(boot.appState.input.texthookerPort, 5174);
|
||||
assert.equal(overlayModalInputStateParams.length, 1);
|
||||
assert.equal(overlayModalInputStateParams[0]?.getModalWindow(), modalWindow);
|
||||
overlayModalInputStateParams[0]?.syncOverlayShortcutsForModal(true);
|
||||
overlayModalInputStateParams[0]?.syncOverlayVisibilityForModal();
|
||||
assert.deepEqual(calls, ['modal-state:true', 'modal:undefined', 'shortcuts:true', 'visibility']);
|
||||
assert.equal(boot.overlayManager, overlayManager);
|
||||
assert.equal(boot.overlayModalRuntime.kind, 'overlay-modal-runtime');
|
||||
});
|
||||
173
src/main/main-boot-services-bootstrap.ts
Normal file
173
src/main/main-boot-services-bootstrap.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { ConfigStartupParseError } from '../config';
|
||||
import {
|
||||
createMainBootServices,
|
||||
type MainBootServicesResult,
|
||||
type OverlayModalInputStateShape,
|
||||
type AppLifecycleShape,
|
||||
} from './boot/services';
|
||||
|
||||
export interface MainBootServicesBootstrapInput<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp,
|
||||
> {
|
||||
system: {
|
||||
platform: NodeJS.Platform;
|
||||
argv: string[];
|
||||
appDataDir: string | undefined;
|
||||
xdgConfigHome: string | undefined;
|
||||
homeDir: string;
|
||||
defaultMpvLogFile: string;
|
||||
envMpvLog: string | undefined;
|
||||
defaultTexthookerPort: number;
|
||||
getDefaultSocketPath: () => string;
|
||||
resolveConfigDir: (input: {
|
||||
platform: NodeJS.Platform;
|
||||
appDataDir: string | undefined;
|
||||
xdgConfigHome: string | undefined;
|
||||
homeDir: string;
|
||||
existsSync: (targetPath: string) => boolean;
|
||||
}) => string;
|
||||
existsSync: (targetPath: string) => boolean;
|
||||
mkdirSync: (targetPath: string, options: { recursive: true }) => void;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
app: {
|
||||
setPath: (name: string, value: string) => void;
|
||||
quit: () => void;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
|
||||
on: Function;
|
||||
whenReady: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
singleInstance: {
|
||||
shouldBypassSingleInstanceLock: () => boolean;
|
||||
requestSingleInstanceLockEarly: () => boolean;
|
||||
registerSecondInstanceHandlerEarly: (
|
||||
listener: (_event: unknown, argv: string[]) => void,
|
||||
) => void;
|
||||
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
|
||||
};
|
||||
factories: {
|
||||
createConfigService: (configDir: string) => TConfigService;
|
||||
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
|
||||
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
|
||||
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
|
||||
createSubtitleWebSocket: () => TSubtitleWebSocket;
|
||||
createLogger: (scope: string) => TLogger & {
|
||||
warn: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
error: (message: string, details?: unknown) => void;
|
||||
};
|
||||
createMainRuntimeRegistry: () => TRuntimeRegistry;
|
||||
createOverlayManager: () => TOverlayManager;
|
||||
createOverlayModalInputState: (params: {
|
||||
getModalWindow: () => BrowserWindow | null;
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
}) => TOverlayModalInputState;
|
||||
createOverlayContentMeasurementStore: (params: {
|
||||
logger: TLogger;
|
||||
}) => TOverlayContentMeasurementStore;
|
||||
getSyncOverlayShortcutsForModal: () => (isActive: boolean) => void;
|
||||
getSyncOverlayVisibilityForModal: () => () => void;
|
||||
createOverlayModalRuntime: (params: {
|
||||
overlayManager: TOverlayManager;
|
||||
overlayModalInputState: TOverlayModalInputState;
|
||||
onModalStateChange: (isActive: boolean) => void;
|
||||
}) => TOverlayModalRuntime;
|
||||
createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => TAppState;
|
||||
};
|
||||
}
|
||||
|
||||
export function createMainBootServicesBootstrap<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager extends { getModalWindow: () => BrowserWindow | null },
|
||||
TOverlayModalInputState extends OverlayModalInputStateShape,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp extends AppLifecycleShape,
|
||||
>(
|
||||
input: MainBootServicesBootstrapInput<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp
|
||||
>,
|
||||
): MainBootServicesResult<
|
||||
TConfigService,
|
||||
TAnilistTokenStore,
|
||||
TJellyfinTokenStore,
|
||||
TAnilistUpdateQueue,
|
||||
TSubtitleWebSocket,
|
||||
TLogger,
|
||||
TRuntimeRegistry,
|
||||
TOverlayManager,
|
||||
TOverlayModalInputState,
|
||||
TOverlayContentMeasurementStore,
|
||||
TOverlayModalRuntime,
|
||||
TAppState,
|
||||
TAppLifecycleApp
|
||||
> {
|
||||
return createMainBootServices({
|
||||
platform: input.system.platform,
|
||||
argv: input.system.argv,
|
||||
appDataDir: input.system.appDataDir,
|
||||
xdgConfigHome: input.system.xdgConfigHome,
|
||||
homeDir: input.system.homeDir,
|
||||
defaultMpvLogFile: input.system.defaultMpvLogFile,
|
||||
envMpvLog: input.system.envMpvLog,
|
||||
defaultTexthookerPort: input.system.defaultTexthookerPort,
|
||||
getDefaultSocketPath: input.system.getDefaultSocketPath,
|
||||
resolveConfigDir: input.system.resolveConfigDir,
|
||||
existsSync: input.system.existsSync,
|
||||
mkdirSync: input.system.mkdirSync,
|
||||
joinPath: input.system.joinPath,
|
||||
app: input.system.app,
|
||||
shouldBypassSingleInstanceLock: input.singleInstance.shouldBypassSingleInstanceLock,
|
||||
requestSingleInstanceLockEarly: input.singleInstance.requestSingleInstanceLockEarly,
|
||||
registerSecondInstanceHandlerEarly: input.singleInstance.registerSecondInstanceHandlerEarly,
|
||||
onConfigStartupParseError: input.singleInstance.onConfigStartupParseError,
|
||||
createConfigService: input.factories.createConfigService,
|
||||
createAnilistTokenStore: input.factories.createAnilistTokenStore,
|
||||
createJellyfinTokenStore: input.factories.createJellyfinTokenStore,
|
||||
createAnilistUpdateQueue: input.factories.createAnilistUpdateQueue,
|
||||
createSubtitleWebSocket: input.factories.createSubtitleWebSocket,
|
||||
createLogger: input.factories.createLogger,
|
||||
createMainRuntimeRegistry: input.factories.createMainRuntimeRegistry,
|
||||
createOverlayManager: input.factories.createOverlayManager,
|
||||
createOverlayModalInputState: input.factories.createOverlayModalInputState,
|
||||
createOverlayContentMeasurementStore: input.factories.createOverlayContentMeasurementStore,
|
||||
getSyncOverlayShortcutsForModal: input.factories.getSyncOverlayShortcutsForModal,
|
||||
getSyncOverlayVisibilityForModal: input.factories.getSyncOverlayVisibilityForModal,
|
||||
createOverlayModalRuntime: input.factories.createOverlayModalRuntime,
|
||||
createAppState: input.factories.createAppState,
|
||||
});
|
||||
}
|
||||
253
src/main/main-early-runtime.ts
Normal file
253
src/main/main-early-runtime.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
import type { ConfigService } from '../config';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import type { AppState } from './state';
|
||||
import { createFirstRunRuntimeCoordinator } from './first-run-runtime-coordinator';
|
||||
import { createStartupSupportFromMainState } from './startup-support-coordinator';
|
||||
import { createYoutubeRuntimeFromMainState } from './youtube-runtime-coordinator';
|
||||
import { createOverlayMpvSubtitleSuppressionRuntime } from './runtime/overlay-mpv-sub-visibility';
|
||||
import { createDiscordPresenceRuntimeFromMainState } from './runtime/discord-presence-runtime';
|
||||
import type { OverlayGeometryRuntime } from './overlay-geometry-runtime';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
||||
import type { OverlayUiRuntime } from './overlay-ui-runtime';
|
||||
|
||||
export interface MainEarlyRuntimeInput {
|
||||
platform: NodeJS.Platform;
|
||||
configDir: string;
|
||||
homeDir: string;
|
||||
xdgConfigHome?: string;
|
||||
binaryPath: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
appDataDir: string;
|
||||
desktopDir: string;
|
||||
defaultImmersionDbPath: string;
|
||||
defaultJimakuLanguagePreference: ResolvedConfig['jimaku']['languagePreference'];
|
||||
defaultJimakuMaxEntryResults: number;
|
||||
defaultJimakuApiBaseUrl: string;
|
||||
jellyfinLangPref: string;
|
||||
youtube: {
|
||||
directPlaybackFormat: string;
|
||||
mpvYtdlFormat: string;
|
||||
autoLaunchTimeoutMs: number;
|
||||
connectTimeoutMs: number;
|
||||
logPath: string;
|
||||
};
|
||||
discordPresenceAppId: string;
|
||||
appState: AppState;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getFallbackDiscordMediaDurationSec: () => number | null;
|
||||
configService: Pick<ConfigService, 'reloadConfigStrict'>;
|
||||
overlay: {
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
};
|
||||
overlayModalRuntime: {
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
getOverlayUi: () => OverlayUiRuntime<BrowserWindow> | null;
|
||||
getOverlayGeometry: () => OverlayGeometryRuntime<BrowserWindow>;
|
||||
ensureTray: () => void;
|
||||
hasTray: () => boolean;
|
||||
};
|
||||
yomitan: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
getParserRuntimeDeps: () => Parameters<
|
||||
typeof import('../core/services').getYomitanDictionaryInfo
|
||||
>[0];
|
||||
openYomitanSettings: () => boolean;
|
||||
};
|
||||
subtitle: {
|
||||
getSubtitle: () => SubtitleRuntime;
|
||||
};
|
||||
tokenization: {
|
||||
startTokenizationWarmups: () => Promise<void>;
|
||||
getGate: Parameters<typeof createYoutubeRuntimeFromMainState>[0]['tokenization']['getGate'];
|
||||
};
|
||||
appReady: {
|
||||
ensureYoutubePlaybackRuntimeReady: () => Promise<void>;
|
||||
};
|
||||
shortcuts: {
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
};
|
||||
notifications: {
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
showErrorBox: (title: string, content: string) => void;
|
||||
};
|
||||
mpv: {
|
||||
sendMpvCommandRuntime: (client: AppState['mpvClient'], command: (string | number)[]) => void;
|
||||
setSubVisibility: (visible: boolean) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
};
|
||||
actions: {
|
||||
requestAppQuit: () => void;
|
||||
writeShortcutLink: (
|
||||
shortcutPath: string,
|
||||
operation: 'create' | 'update' | 'replace',
|
||||
details: {
|
||||
target: string;
|
||||
args?: string;
|
||||
cwd?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
iconIndex?: number;
|
||||
},
|
||||
) => boolean;
|
||||
};
|
||||
logger: {
|
||||
error: (message: string, error?: unknown) => void;
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, error?: unknown) => void;
|
||||
debug: (message: string, meta?: unknown) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function createMainEarlyRuntime(input: MainEarlyRuntimeInput) {
|
||||
const firstRun = createFirstRunRuntimeCoordinator({
|
||||
platform: input.platform,
|
||||
configDir: input.configDir,
|
||||
homeDir: input.homeDir,
|
||||
xdgConfigHome: input.xdgConfigHome,
|
||||
binaryPath: input.binaryPath,
|
||||
appPath: input.appPath,
|
||||
resourcesPath: input.resourcesPath,
|
||||
appDataDir: input.appDataDir,
|
||||
desktopDir: input.desktopDir,
|
||||
appState: input.appState,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
yomitan: input.yomitan,
|
||||
overlay: {
|
||||
ensureTray: () => input.overlay.ensureTray(),
|
||||
hasTray: () => input.overlay.hasTray(),
|
||||
},
|
||||
actions: {
|
||||
writeShortcutLink: (shortcutPath, operation, details) =>
|
||||
input.actions.writeShortcutLink(shortcutPath, operation, details),
|
||||
requestAppQuit: () => input.actions.requestAppQuit(),
|
||||
},
|
||||
logger: {
|
||||
error: (message, error) => input.logger.error(message, error),
|
||||
info: (message, ...args) => input.logger.info(message, ...args),
|
||||
},
|
||||
});
|
||||
|
||||
const { discordPresenceRuntime, initializeDiscordPresenceService } =
|
||||
createDiscordPresenceRuntimeFromMainState({
|
||||
appId: input.discordPresenceAppId,
|
||||
appState: input.appState,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
getFallbackMediaDurationSec: () => input.getFallbackDiscordMediaDurationSec(),
|
||||
logger: {
|
||||
debug: (message, meta) => input.logger.debug(message, meta),
|
||||
},
|
||||
});
|
||||
|
||||
const overlaySubtitleSuppression = createOverlayMpvSubtitleSuppressionRuntime({
|
||||
appState: input.appState,
|
||||
getVisibleOverlayVisible: () => input.overlay.overlayManager.getVisibleOverlayVisible(),
|
||||
setMpvSubVisibility: (visible) => input.mpv.setSubVisibility(visible),
|
||||
logWarn: (message, error) => input.logger.warn(message, error),
|
||||
});
|
||||
|
||||
const startupSupport = createStartupSupportFromMainState({
|
||||
platform: input.platform,
|
||||
defaultImmersionDbPath: input.defaultImmersionDbPath,
|
||||
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
|
||||
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
|
||||
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
|
||||
jellyfinLangPref: input.jellyfinLangPref,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
appState: input.appState,
|
||||
configService: input.configService,
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
input.overlay.overlayModalRuntime.sendToActiveOverlayWindow(
|
||||
channel,
|
||||
payload,
|
||||
runtimeOptions,
|
||||
),
|
||||
},
|
||||
shortcuts: {
|
||||
refreshGlobalAndOverlayShortcuts: () => input.shortcuts.refreshGlobalAndOverlayShortcuts(),
|
||||
},
|
||||
notifications: {
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.notifications.showDesktopNotification(title, options),
|
||||
showErrorBox: (title, details) => input.notifications.showErrorBox(title, details),
|
||||
},
|
||||
logger: {
|
||||
debug: (message) => input.logger.debug(message),
|
||||
info: (message) => input.logger.info(message),
|
||||
warn: (message, error) => input.logger.warn(message, error),
|
||||
},
|
||||
mpv: {
|
||||
sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command),
|
||||
showMpvOsd: (text) => input.mpv.showMpvOsd(text),
|
||||
},
|
||||
});
|
||||
|
||||
const youtube = createYoutubeRuntimeFromMainState({
|
||||
platform: input.platform,
|
||||
directPlaybackFormat: input.youtube.directPlaybackFormat,
|
||||
mpvYtdlFormat: input.youtube.mpvYtdlFormat,
|
||||
autoLaunchTimeoutMs: input.youtube.autoLaunchTimeoutMs,
|
||||
connectTimeoutMs: input.youtube.connectTimeoutMs,
|
||||
logPath: input.youtube.logPath,
|
||||
appState: input.appState,
|
||||
overlay: {
|
||||
getOverlayUi: () => input.overlay.getOverlayUi(),
|
||||
getMainWindow: () => input.overlay.overlayManager.getMainWindow(),
|
||||
getOverlayGeometry: () => input.overlay.getOverlayGeometry(),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload),
|
||||
},
|
||||
subtitle: {
|
||||
getSubtitle: () => input.subtitle.getSubtitle(),
|
||||
},
|
||||
tokenization: {
|
||||
startTokenizationWarmups: () => input.tokenization.startTokenizationWarmups(),
|
||||
getGate: () => input.tokenization.getGate(),
|
||||
},
|
||||
appReady: {
|
||||
ensureYoutubePlaybackRuntimeReady: () => input.appReady.ensureYoutubePlaybackRuntimeReady(),
|
||||
},
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
notifications: {
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.notifications.showDesktopNotification(title, options),
|
||||
showErrorBox: (title, content) => input.notifications.showErrorBox(title, content),
|
||||
},
|
||||
mpv: {
|
||||
sendMpvCommand: (command) =>
|
||||
input.mpv.sendMpvCommandRuntime(input.appState.mpvClient, command),
|
||||
showMpvOsd: (message) => input.mpv.showMpvOsd(message),
|
||||
},
|
||||
logger: {
|
||||
info: (message) => input.logger.info(message),
|
||||
warn: (message, error) => input.logger.warn(message, error),
|
||||
debug: (message) => input.logger.debug(message),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
firstRun,
|
||||
discordPresenceRuntime,
|
||||
initializeDiscordPresenceService,
|
||||
overlaySubtitleSuppression,
|
||||
startupSupport,
|
||||
youtube,
|
||||
};
|
||||
}
|
||||
129
src/main/main-playback-runtime.ts
Normal file
129
src/main/main-playback-runtime.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import type { MpvSubtitleRenderMetrics } from '../types';
|
||||
import type { MpvIpcClient } from '../core/services/mpv';
|
||||
import { sendMpvCommandRuntime } from '../core/services';
|
||||
import type { AnilistRuntime } from './anilist-runtime';
|
||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
||||
import type { JellyfinRuntime } from './jellyfin-runtime';
|
||||
import { createMiningRuntime } from './mining-runtime';
|
||||
import type { MiningRuntimeInput } from './mining-runtime';
|
||||
import { createMpvRuntimeFromMainState } from './mpv-runtime-bootstrap';
|
||||
import type { MpvRuntime } from './mpv-runtime';
|
||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
||||
import type { YoutubeRuntime } from './youtube-runtime';
|
||||
import type { AppState } from './state';
|
||||
|
||||
export interface MainPlaybackRuntimeInput {
|
||||
appState: AppState;
|
||||
logPath: string;
|
||||
logger: Parameters<typeof createMpvRuntimeFromMainState>[0]['logger'] & {
|
||||
error: (message: string, error: unknown) => void;
|
||||
};
|
||||
getResolvedConfig: Parameters<typeof createMpvRuntimeFromMainState>[0]['getResolvedConfig'];
|
||||
getRuntimeBooleanOption: Parameters<
|
||||
typeof createMpvRuntimeFromMainState
|
||||
>[0]['getRuntimeBooleanOption'];
|
||||
subtitle: SubtitleRuntime;
|
||||
yomitan: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
};
|
||||
currentMediaTokenizationGate: Parameters<
|
||||
typeof createMpvRuntimeFromMainState
|
||||
>[0]['currentMediaTokenizationGate'];
|
||||
startupOsdSequencer: Parameters<typeof createMpvRuntimeFromMainState>[0]['startupOsdSequencer'];
|
||||
dictionarySupport: DictionarySupportRuntime;
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined;
|
||||
};
|
||||
lifecycle: {
|
||||
requestAppQuit: () => void;
|
||||
restoreOverlayMpvSubtitles: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
publishDiscordPresence: () => void;
|
||||
};
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
};
|
||||
anilist: AnilistRuntime;
|
||||
jellyfin: JellyfinRuntime;
|
||||
youtube: YoutubeRuntime;
|
||||
mining: Omit<
|
||||
MiningRuntimeInput<any, any>,
|
||||
'showMpvOsd' | 'sendMpvCommand' | 'logError' | 'recordCardsMined'
|
||||
> & {
|
||||
readClipboardText: () => string;
|
||||
writeClipboardText: (text: string) => void;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MainPlaybackRuntime {
|
||||
mpvRuntime: MpvRuntime;
|
||||
mining: ReturnType<typeof createMiningRuntime>;
|
||||
}
|
||||
|
||||
export function createMainPlaybackRuntime(input: MainPlaybackRuntimeInput): MainPlaybackRuntime {
|
||||
let mpvRuntime!: MpvRuntime;
|
||||
|
||||
const showMpvOsd = (text: string): void => {
|
||||
mpvRuntime.showMpvOsd(text);
|
||||
};
|
||||
|
||||
const mining = createMiningRuntime({
|
||||
...input.mining,
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
sendMpvCommand: (command) => {
|
||||
sendMpvCommandRuntime(input.appState.mpvClient, command);
|
||||
},
|
||||
logError: (message, err) => {
|
||||
input.logger.error(message, err);
|
||||
},
|
||||
recordCardsMined: (count, noteIds) => input.mining.recordCardsMined(count, noteIds),
|
||||
});
|
||||
|
||||
mpvRuntime = createMpvRuntimeFromMainState({
|
||||
appState: input.appState,
|
||||
logPath: input.logPath,
|
||||
logger: input.logger,
|
||||
getResolvedConfig: input.getResolvedConfig,
|
||||
getRuntimeBooleanOption: input.getRuntimeBooleanOption,
|
||||
subtitle: input.subtitle,
|
||||
yomitan: {
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
||||
},
|
||||
},
|
||||
currentMediaTokenizationGate: input.currentMediaTokenizationGate,
|
||||
startupOsdSequencer: input.startupOsdSequencer,
|
||||
dictionarySupport: input.dictionarySupport,
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
input.overlay.broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
|
||||
getOverlayUi: () => input.overlay.getOverlayUi(),
|
||||
},
|
||||
lifecycle: {
|
||||
requestAppQuit: () => input.lifecycle.requestAppQuit(),
|
||||
setQuitCheckTimer: (callback, timeoutMs) => {
|
||||
setTimeout(callback, timeoutMs);
|
||||
},
|
||||
restoreOverlayMpvSubtitles: input.lifecycle.restoreOverlayMpvSubtitles,
|
||||
syncOverlayMpvSubtitleSuppression: input.lifecycle.syncOverlayMpvSubtitleSuppression,
|
||||
publishDiscordPresence: () => input.lifecycle.publishDiscordPresence(),
|
||||
},
|
||||
stats: input.stats,
|
||||
anilist: input.anilist,
|
||||
jellyfin: input.jellyfin,
|
||||
youtube: input.youtube,
|
||||
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
|
||||
}).mpvRuntime;
|
||||
|
||||
return {
|
||||
mpvRuntime,
|
||||
mining,
|
||||
};
|
||||
}
|
||||
54
src/main/main-startup-bootstrap-types.ts
Normal file
54
src/main/main-startup-bootstrap-types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import type { ResolvedConfig, SecondarySubMode, SubtitleData } from '../types';
|
||||
import { RuntimeOptionsManager } from '../runtime-options';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
|
||||
export type StartupBootstrapMpvClientLike = {
|
||||
connected: boolean;
|
||||
connect: () => void;
|
||||
setSocketPath: (socketPath: string) => void;
|
||||
currentSubStart?: number | null;
|
||||
currentSubEnd?: number | null;
|
||||
};
|
||||
|
||||
export type StartupBootstrapAppStateLike = {
|
||||
subtitlePosition: unknown | null;
|
||||
keybindings: unknown[];
|
||||
mpvSocketPath: string;
|
||||
texthookerPort: number;
|
||||
mpvClient: StartupBootstrapMpvClientLike | null;
|
||||
runtimeOptionsManager: RuntimeOptionsManager | null;
|
||||
subtitleTimingTracker: SubtitleTimingTracker | null;
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
currentSubText: string | null;
|
||||
initialArgs: CliArgs | null | undefined;
|
||||
backgroundMode: boolean;
|
||||
texthookerOnlyMode: boolean;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
firstRunSetupCompleted: boolean;
|
||||
secondarySubMode: SecondarySubMode;
|
||||
ankiIntegration: unknown | null;
|
||||
immersionTracker: unknown | null;
|
||||
};
|
||||
|
||||
export type StartupBootstrapSubtitleWebsocketLike = {
|
||||
start: (
|
||||
port: number,
|
||||
getPayload: () => SubtitleData | null,
|
||||
getFrequencyOptions: () => {
|
||||
enabled: boolean;
|
||||
topX: number;
|
||||
mode: ResolvedConfig['subtitleStyle']['frequencyDictionary']['mode'];
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type StartupBootstrapOverlayUiLike = {
|
||||
broadcastRuntimeOptionsChanged: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
ensureTray: () => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
};
|
||||
505
src/main/main-startup-bootstrap.ts
Normal file
505
src/main/main-startup-bootstrap.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
||||
import type { LogLevelSource } from '../logger';
|
||||
import type { ConfigValidationWarning, ResolvedConfig, SubtitleData } from '../types';
|
||||
import type { StartupBootstrapRuntimeDeps } from '../core/services/startup';
|
||||
import { resolveKeybindings } from '../core/utils';
|
||||
import { RuntimeOptionsManager } from '../runtime-options';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import type { AppReadyRuntimeInput } from './app-ready-runtime';
|
||||
import type {
|
||||
CliCommandRuntimeServiceContext,
|
||||
CliCommandRuntimeServiceContextHandlers,
|
||||
} from './cli-runtime';
|
||||
import type {
|
||||
StartupBootstrapAppStateLike,
|
||||
StartupBootstrapMpvClientLike,
|
||||
StartupBootstrapOverlayUiLike,
|
||||
StartupBootstrapSubtitleWebsocketLike,
|
||||
} from './main-startup-bootstrap-types';
|
||||
import type { MainStartupRuntime } from './main-startup-runtime';
|
||||
import { createMainStartupRuntime } from './main-startup-runtime';
|
||||
|
||||
export interface MainStartupBootstrapInput<TStartupState> {
|
||||
appState: StartupBootstrapAppStateLike;
|
||||
appLifecycle: {
|
||||
app: unknown;
|
||||
argv: string[];
|
||||
platform: NodeJS.Platform;
|
||||
};
|
||||
config: {
|
||||
configService: {
|
||||
reloadConfigStrict: AppReadyRuntimeInput['reload']['reloadConfigStrict'];
|
||||
getConfigPath: () => string;
|
||||
getWarnings: () => ConfigValidationWarning[];
|
||||
getConfig: () => ResolvedConfig;
|
||||
};
|
||||
configHotReloadRuntime: {
|
||||
start: () => void;
|
||||
};
|
||||
configDerivedRuntime: {
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => boolean;
|
||||
};
|
||||
ensureDefaultConfigBootstrap: (options: {
|
||||
configDir: string;
|
||||
configFilePaths: unknown;
|
||||
generateTemplate: () => string;
|
||||
}) => void;
|
||||
getDefaultConfigFilePaths: (configDir: string) => unknown;
|
||||
generateConfigTemplate: (config: ResolvedConfig) => string;
|
||||
defaultConfig: ResolvedConfig;
|
||||
defaultKeybindings: unknown;
|
||||
configDir: string;
|
||||
};
|
||||
logging: {
|
||||
appLogger: {
|
||||
logInfo: (message: string) => void;
|
||||
logWarning: (message: string) => void;
|
||||
logConfigWarning: (warning: ConfigValidationWarning) => void;
|
||||
logNoRunningInstance: () => void;
|
||||
};
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, error?: unknown) => void;
|
||||
error: (message: string, error?: unknown) => void;
|
||||
debug: (message: string) => void;
|
||||
};
|
||||
setLogLevel: (level: string, source: LogLevelSource) => void;
|
||||
};
|
||||
shell: {
|
||||
dialog: {
|
||||
showErrorBox: (title: string, message: string) => void;
|
||||
};
|
||||
shell: {
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
};
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
};
|
||||
runtime: {
|
||||
subtitle: {
|
||||
loadSubtitlePosition: () => void;
|
||||
invalidateTokenizationCache: () => void;
|
||||
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
|
||||
};
|
||||
overlayUi: {
|
||||
get: () => StartupBootstrapOverlayUiLike | undefined;
|
||||
};
|
||||
overlayManager: {
|
||||
getMainWindow: () => unknown | null;
|
||||
};
|
||||
firstRun: {
|
||||
ensureSetupStateInitialized: () => Promise<{ state: { status: string } }>;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
};
|
||||
anilist: {
|
||||
refreshAnilistClientSecretStateIfEnabled: (options: {
|
||||
force: boolean;
|
||||
allowSetupPrompt?: boolean;
|
||||
}) => Promise<unknown>;
|
||||
openAnilistSetupWindow: () => void;
|
||||
getStatusSnapshot: CliCommandRuntimeServiceContext['getAnilistStatus'];
|
||||
clearTokenState: () => void;
|
||||
getQueueStatusSnapshot: CliCommandRuntimeServiceContext['getAnilistQueueStatus'];
|
||||
processNextAnilistRetryUpdate: CliCommandRuntimeServiceContext['retryAnilistQueueNow'];
|
||||
};
|
||||
jellyfin: {
|
||||
startJellyfinRemoteSession: () => Promise<void>;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
runJellyfinCommand: (argsFromCommand: CliArgs) => Promise<void>;
|
||||
};
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
runStatsCliCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
||||
};
|
||||
mining: {
|
||||
copyCurrentSubtitle: () => void;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
};
|
||||
yomitan: {
|
||||
loadYomitanExtension: () => Promise<unknown>;
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
openYomitanSettings: () => void;
|
||||
};
|
||||
subsyncRuntime: {
|
||||
triggerFromConfig: () => Promise<void>;
|
||||
};
|
||||
dictionarySupport: {
|
||||
generateCharacterDictionaryForCurrentMedia: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||
};
|
||||
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
|
||||
subtitleWsService: StartupBootstrapSubtitleWebsocketLike;
|
||||
annotationSubtitleWsService: StartupBootstrapSubtitleWebsocketLike;
|
||||
immersion: AppReadyRuntimeInput['immersion'];
|
||||
};
|
||||
commands: {
|
||||
createMpvClientRuntimeService: () => StartupBootstrapMpvClientLike;
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries: () => Promise<void>;
|
||||
startBackgroundWarmupsIfAllowed: () => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
runHeadlessInitialCommand: () => Promise<void>;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
refreshOverlayShortcuts: () => void;
|
||||
hasMpvWebsocketPlugin: () => boolean;
|
||||
startTexthooker: (port: number, websocketUrl?: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
shouldAutoOpenFirstRunSetup: (args: CliArgs) => boolean;
|
||||
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
|
||||
runYoutubePlaybackFlow: (request: {
|
||||
url: string;
|
||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||
source: CliCommandSource;
|
||||
}) => Promise<void>;
|
||||
getMultiCopyTimeoutMs: () => number;
|
||||
shouldEnsureTrayOnStartupForInitialArgs: (
|
||||
platform: NodeJS.Platform,
|
||||
initialArgs: CliArgs | null | undefined,
|
||||
) => boolean;
|
||||
isHeadlessInitialCommand: (args: CliArgs) => boolean;
|
||||
commandNeedsOverlayStartupPrereqs: (args: CliArgs) => boolean;
|
||||
commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
|
||||
handleCliCommandRuntimeServiceWithContext: (
|
||||
args: CliArgs,
|
||||
source: CliCommandSource,
|
||||
context: CliCommandRuntimeServiceContext & CliCommandRuntimeServiceContextHandlers,
|
||||
) => void;
|
||||
shouldStartApp: (args: CliArgs) => boolean;
|
||||
parseArgs: (argv: string[]) => CliArgs;
|
||||
printHelp: (defaultTexthookerPort: number) => void;
|
||||
onWillQuitCleanupHandler: () => void;
|
||||
shouldRestoreWindowsOnActivateHandler: () => boolean;
|
||||
restoreWindowsOnActivateHandler: () => void;
|
||||
forceX11Backend: (args: CliArgs) => void;
|
||||
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
|
||||
getDefaultSocketPathHandler: () => string;
|
||||
generateDefaultConfigFile: (
|
||||
args: CliArgs,
|
||||
options: {
|
||||
configDir: string;
|
||||
defaultConfig: unknown;
|
||||
generateTemplate: (config: unknown) => string;
|
||||
},
|
||||
) => Promise<number>;
|
||||
runStartupBootstrapRuntime: (deps: StartupBootstrapRuntimeDeps) => TStartupState;
|
||||
applyStartupState: (startupState: TStartupState) => void;
|
||||
getStartupModeFlags: (initialArgs: CliArgs | null | undefined) => {
|
||||
shouldUseMinimalStartup: boolean;
|
||||
shouldSkipHeavyStartup: boolean;
|
||||
};
|
||||
requestAppQuit: () => void;
|
||||
};
|
||||
constants: {
|
||||
defaultTexthookerPort: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function createMainStartupBootstrap<TStartupState>(
|
||||
input: MainStartupBootstrapInput<TStartupState>,
|
||||
): MainStartupRuntime<TStartupState> {
|
||||
let startup: MainStartupRuntime<TStartupState> | null = null;
|
||||
const getStartup = (): MainStartupRuntime<TStartupState> => {
|
||||
if (!startup) {
|
||||
throw new Error('Main startup runtime not initialized');
|
||||
}
|
||||
return startup;
|
||||
};
|
||||
const getOverlayUi = (): StartupBootstrapOverlayUiLike | undefined =>
|
||||
input.runtime.overlayUi.get();
|
||||
const getSubtitlePayload = (): SubtitleData | null =>
|
||||
input.appState.currentSubtitleData ??
|
||||
(input.appState.currentSubText
|
||||
? {
|
||||
text: input.appState.currentSubText,
|
||||
tokens: null,
|
||||
startTime: input.appState.mpvClient?.currentSubStart ?? null,
|
||||
endTime: input.appState.mpvClient?.currentSubEnd ?? null,
|
||||
}
|
||||
: null);
|
||||
const getSubtitleFrequencyOptions = () => ({
|
||||
enabled: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
topX: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.topX,
|
||||
mode: input.config.configService.getConfig().subtitleStyle.frequencyDictionary.mode,
|
||||
});
|
||||
|
||||
startup = createMainStartupRuntime<TStartupState>({
|
||||
appReady: {
|
||||
reload: {
|
||||
reloadConfigStrict: () => input.config.configService.reloadConfigStrict(),
|
||||
logInfo: (message) => input.logging.appLogger.logInfo(message),
|
||||
logWarning: (message) => input.logging.appLogger.logWarning(message),
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.shell.showDesktopNotification(title, options),
|
||||
startConfigHotReload: () => input.config.configHotReloadRuntime.start(),
|
||||
refreshAnilistClientSecretState: (options) =>
|
||||
input.runtime.anilist.refreshAnilistClientSecretStateIfEnabled(options),
|
||||
failHandlers: {
|
||||
logError: (details) => input.logging.logger.error(details),
|
||||
showErrorBox: (title, details) => input.shell.dialog.showErrorBox(title, details),
|
||||
quit: () => input.commands.requestAppQuit(),
|
||||
},
|
||||
},
|
||||
criticalConfig: {
|
||||
getConfigPath: () => input.config.configService.getConfigPath(),
|
||||
failHandlers: {
|
||||
logError: (message) => input.logging.logger.error(message),
|
||||
showErrorBox: (title, message) => input.shell.dialog.showErrorBox(title, message),
|
||||
quit: () => input.commands.requestAppQuit(),
|
||||
},
|
||||
},
|
||||
runner: {
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
input.config.ensureDefaultConfigBootstrap({
|
||||
configDir: input.config.configDir,
|
||||
configFilePaths: input.config.getDefaultConfigFilePaths(input.config.configDir),
|
||||
generateTemplate: () => input.config.generateConfigTemplate(input.config.defaultConfig),
|
||||
});
|
||||
},
|
||||
getSubtitlePosition: () => input.appState.subtitlePosition,
|
||||
loadSubtitlePosition: () => input.runtime.subtitle.loadSubtitlePosition(),
|
||||
getKeybindingsCount: () => input.appState.keybindings.length,
|
||||
resolveKeybindings: () => {
|
||||
input.appState.keybindings = resolveKeybindings(
|
||||
input.config.configService.getConfig(),
|
||||
input.config.defaultKeybindings as never,
|
||||
);
|
||||
},
|
||||
hasMpvClient: () => Boolean(input.appState.mpvClient),
|
||||
createMpvClient: () => {
|
||||
input.appState.mpvClient = input.commands.createMpvClientRuntimeService();
|
||||
},
|
||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
||||
getResolvedConfig: () => input.config.configService.getConfig(),
|
||||
getConfigWarnings: () => input.config.configService.getWarnings(),
|
||||
logConfigWarning: (warning) => input.logging.appLogger.logConfigWarning(warning),
|
||||
setLogLevel: (level, source) => input.logging.setLogLevel(level, source),
|
||||
initRuntimeOptionsManager: () => {
|
||||
input.appState.runtimeOptionsManager = new RuntimeOptionsManager(
|
||||
() => input.config.configService.getConfig().ankiConnect,
|
||||
{
|
||||
applyAnkiPatch: (patch: unknown) => {
|
||||
(
|
||||
input.appState.ankiIntegration as {
|
||||
applyRuntimeConfigPatch?: (patch: unknown) => void;
|
||||
} | null
|
||||
)?.applyRuntimeConfigPatch?.(patch);
|
||||
},
|
||||
getSubtitleStyleConfig: () => input.config.configService.getConfig().subtitleStyle,
|
||||
onOptionsChanged: () => {
|
||||
input.runtime.subtitle.invalidateTokenizationCache();
|
||||
void input.runtime.subtitle.refreshSubtitlePrefetchFromActiveTrack();
|
||||
getOverlayUi()?.broadcastRuntimeOptionsChanged();
|
||||
input.commands.refreshOverlayShortcuts();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
getSubtitleTimingTracker: () => input.appState.subtitleTimingTracker,
|
||||
createSubtitleTimingTracker: () => {
|
||||
input.appState.subtitleTimingTracker = new SubtitleTimingTracker();
|
||||
},
|
||||
setSecondarySubMode: (mode) => {
|
||||
input.appState.secondarySubMode = mode;
|
||||
},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: input.config.defaultConfig.websocket.port,
|
||||
defaultAnnotationWebsocketPort: input.config.defaultConfig.annotationWebsocket.port,
|
||||
defaultTexthookerPort: input.constants.defaultTexthookerPort,
|
||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
||||
startSubtitleWebsocket: (port) => {
|
||||
input.runtime.subtitleWsService.start(
|
||||
port,
|
||||
getSubtitlePayload,
|
||||
getSubtitleFrequencyOptions,
|
||||
);
|
||||
},
|
||||
startAnnotationWebsocket: (port) => {
|
||||
input.runtime.annotationSubtitleWsService.start(
|
||||
port,
|
||||
getSubtitlePayload,
|
||||
getSubtitleFrequencyOptions,
|
||||
);
|
||||
},
|
||||
startTexthooker: (port, websocketUrl) => input.commands.startTexthooker(port, websocketUrl),
|
||||
log: (message) => input.logging.appLogger.logInfo(message),
|
||||
createMecabTokenizerAndCheck: () => input.commands.createMecabTokenizerAndCheck(),
|
||||
createImmersionTracker: () => {
|
||||
input.runtime.stats.ensureImmersionTrackerStarted();
|
||||
},
|
||||
startJellyfinRemoteSession: () => input.runtime.jellyfin.startJellyfinRemoteSession(),
|
||||
loadYomitanExtension: async () => {
|
||||
await input.runtime.yomitan.loadYomitanExtension();
|
||||
},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
await input.runtime.yomitan.ensureYomitanExtensionLoaded();
|
||||
},
|
||||
handleFirstRunSetup: async () => {
|
||||
const snapshot = await input.runtime.firstRun.ensureSetupStateInitialized();
|
||||
input.appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
|
||||
if (
|
||||
input.appState.initialArgs &&
|
||||
input.commands.shouldAutoOpenFirstRunSetup(input.appState.initialArgs) &&
|
||||
snapshot.state.status !== 'completed'
|
||||
) {
|
||||
input.runtime.firstRun.openFirstRunSetupWindow();
|
||||
}
|
||||
},
|
||||
prewarmSubtitleDictionaries: () => input.commands.prewarmSubtitleDictionaries(),
|
||||
startBackgroundWarmups: () => input.commands.startBackgroundWarmupsIfAllowed(),
|
||||
texthookerOnlyMode: input.appState.texthookerOnlyMode,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
|
||||
input.appState.backgroundMode
|
||||
? false
|
||||
: input.config.configDerivedRuntime.shouldAutoInitializeOverlayRuntimeFromConfig(),
|
||||
setVisibleOverlayVisible: (visible) => getOverlayUi()?.setVisibleOverlayVisible(visible),
|
||||
initializeOverlayRuntime: () => getOverlayUi()?.initializeOverlayRuntime(),
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
||||
getOverlayUi()?.ensureOverlayWindowsReadyForVisibilityActions(),
|
||||
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
|
||||
handleInitialArgs: () => getStartup().handleInitialArgs(),
|
||||
shouldRunHeadlessInitialCommand: () =>
|
||||
Boolean(
|
||||
input.appState.initialArgs &&
|
||||
input.commands.isHeadlessInitialCommand(input.appState.initialArgs),
|
||||
),
|
||||
shouldUseMinimalStartup: () =>
|
||||
input.commands.getStartupModeFlags(input.appState.initialArgs).shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: () =>
|
||||
input.commands.getStartupModeFlags(input.appState.initialArgs).shouldSkipHeavyStartup,
|
||||
logDebug: (message) => input.logging.logger.debug(message),
|
||||
now: () => Date.now(),
|
||||
},
|
||||
immersion: input.runtime.immersion,
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
},
|
||||
cli: {
|
||||
appState: {
|
||||
appState: input.appState,
|
||||
getInitialArgs: () => input.appState.initialArgs,
|
||||
isBackgroundMode: () => input.appState.backgroundMode,
|
||||
isTexthookerOnlyMode: () => input.appState.texthookerOnlyMode,
|
||||
setTexthookerOnlyMode: (enabled) => {
|
||||
input.appState.texthookerOnlyMode = enabled;
|
||||
},
|
||||
hasImmersionTracker: () => Boolean(input.appState.immersionTracker),
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
},
|
||||
config: {
|
||||
defaultConfig: input.config.defaultConfig,
|
||||
getResolvedConfig: () => input.config.configService.getConfig(),
|
||||
setCliLogLevel: (level) => input.logging.setLogLevel(level, 'cli'),
|
||||
hasMpvWebsocketPlugin: () => true,
|
||||
},
|
||||
io: {
|
||||
texthookerService: input.runtime.texthookerService,
|
||||
openExternal: (url) => input.shell.shell.openExternal(url),
|
||||
logBrowserOpenError: (url, error) =>
|
||||
input.logging.logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
|
||||
showMpvOsd: (text) => input.commands.showMpvOsd(text),
|
||||
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
||||
logInfo: (message) => input.logging.logger.info(message),
|
||||
logWarn: (message) => input.logging.logger.warn(message),
|
||||
logError: (message, err) => input.logging.logger.error(message, err),
|
||||
},
|
||||
commands: {
|
||||
initializeOverlayRuntime: () => getOverlayUi()?.initializeOverlayRuntime(),
|
||||
toggleVisibleOverlay: () => getOverlayUi()?.toggleVisibleOverlay(),
|
||||
openFirstRunSetupWindow: () => input.runtime.firstRun.openFirstRunSetupWindow(),
|
||||
setVisibleOverlayVisible: (visible) => getOverlayUi()?.setVisibleOverlayVisible(visible),
|
||||
copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(),
|
||||
startPendingMultiCopy: (timeoutMs) => input.commands.startPendingMultiCopy(timeoutMs),
|
||||
mineSentenceCard: () => input.runtime.mining.mineSentenceCard(),
|
||||
startPendingMineSentenceMultiple: (timeoutMs) =>
|
||||
input.commands.startPendingMineSentenceMultiple(timeoutMs),
|
||||
updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(),
|
||||
refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(),
|
||||
triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => input.runtime.subsyncRuntime.triggerFromConfig(),
|
||||
markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(),
|
||||
getAnilistStatus: () => input.runtime.anilist.getStatusSnapshot(),
|
||||
clearAnilistToken: () => input.runtime.anilist.clearTokenState(),
|
||||
openAnilistSetupWindow: () => input.runtime.anilist.openAnilistSetupWindow(),
|
||||
openJellyfinSetupWindow: () => input.runtime.jellyfin.openJellyfinSetupWindow(),
|
||||
getAnilistQueueStatus: () => input.runtime.anilist.getQueueStatusSnapshot(),
|
||||
processNextAnilistRetryUpdate: () => input.runtime.anilist.processNextAnilistRetryUpdate(),
|
||||
generateCharacterDictionary: (targetPath?: string) =>
|
||||
input.commands.generateCharacterDictionary(targetPath),
|
||||
runJellyfinCommand: (argsFromCommand) =>
|
||||
input.runtime.jellyfin.runJellyfinCommand(argsFromCommand),
|
||||
runStatsCommand: (argsFromCommand, source) =>
|
||||
input.runtime.stats.runStatsCliCommand(argsFromCommand, source),
|
||||
runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request),
|
||||
openYomitanSettings: () => input.runtime.yomitan.openYomitanSettings(),
|
||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => getOverlayUi()?.openRuntimeOptionsPalette(),
|
||||
printHelp: () => input.commands.printHelp(input.constants.defaultTexthookerPort),
|
||||
stopApp: () => input.commands.requestAppQuit(),
|
||||
hasMainWindow: () => Boolean(input.runtime.overlayManager.getMainWindow()),
|
||||
getMultiCopyTimeoutMs: () => input.commands.getMultiCopyTimeoutMs(),
|
||||
},
|
||||
startup: {
|
||||
shouldEnsureTrayOnStartup: () =>
|
||||
input.commands.shouldEnsureTrayOnStartupForInitialArgs(
|
||||
input.appLifecycle.platform,
|
||||
input.appState.initialArgs,
|
||||
),
|
||||
shouldRunHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
|
||||
ensureTray: () => getOverlayUi()?.ensureTray(),
|
||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
||||
input.commands.commandNeedsOverlayStartupPrereqs(args),
|
||||
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
|
||||
ensureOverlayStartupPrereqs: () => getStartup().appReady.ensureOverlayStartupPrereqs(),
|
||||
startBackgroundWarmups: () => input.commands.startBackgroundWarmups(),
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (args, source, context) =>
|
||||
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, context),
|
||||
},
|
||||
headless: {
|
||||
appLifecycleRuntimeRunnerMainDeps: {
|
||||
app: input.appLifecycle.app as never,
|
||||
platform: input.appLifecycle.platform,
|
||||
shouldStartApp: (nextArgs) => input.commands.shouldStartApp(nextArgs),
|
||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
||||
handleCliCommand: (nextArgs, source) => getStartup().handleCliCommand(nextArgs, source),
|
||||
printHelp: () => input.commands.printHelp(input.constants.defaultTexthookerPort),
|
||||
logNoRunningInstance: () => input.logging.appLogger.logNoRunningInstance(),
|
||||
onReady: (): Promise<void> => getStartup().appReady.runAppReady(),
|
||||
onWillQuitCleanup: () => input.commands.onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivate: () =>
|
||||
input.commands.shouldRestoreWindowsOnActivateHandler(),
|
||||
restoreWindowsOnActivate: () => input.commands.restoreWindowsOnActivateHandler(),
|
||||
shouldQuitOnWindowAllClosed: () => !input.appState.backgroundMode,
|
||||
},
|
||||
bootstrap: {
|
||||
argv: input.appLifecycle.argv,
|
||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
||||
setLogLevel: (level, source) => input.logging.setLogLevel(level, source),
|
||||
forceX11Backend: (args) => input.commands.forceX11Backend(args),
|
||||
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
|
||||
shouldStartApp: (args) => input.commands.shouldStartApp(args),
|
||||
getDefaultSocketPath: () => input.commands.getDefaultSocketPathHandler(),
|
||||
defaultTexthookerPort: input.constants.defaultTexthookerPort,
|
||||
configDir: input.config.configDir,
|
||||
defaultConfig: input.config.defaultConfig,
|
||||
generateConfigTemplate: (config) => input.config.generateConfigTemplate(config),
|
||||
generateDefaultConfigFile: (args, options) =>
|
||||
input.commands.generateDefaultConfigFile(args, options),
|
||||
setExitCode: (exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
},
|
||||
quitApp: () => input.commands.requestAppQuit(),
|
||||
logGenerateConfigError: (message) => input.logging.logger.error(message),
|
||||
startAppLifecycle: () => {},
|
||||
},
|
||||
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
|
||||
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
|
||||
},
|
||||
});
|
||||
|
||||
return startup;
|
||||
}
|
||||
262
src/main/main-startup-runtime-bootstrap.ts
Normal file
262
src/main/main-startup-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import type { MainStartupBootstrapInput } from './main-startup-bootstrap';
|
||||
import type { MainStartupRuntime } from './main-startup-runtime';
|
||||
import type { FirstRunRuntime } from './first-run-runtime';
|
||||
import { createMainStartupBootstrap } from './main-startup-bootstrap';
|
||||
|
||||
type StartupBootstrapYomitanRuntime<TStartupState> = {
|
||||
loadYomitanExtension: MainStartupBootstrapInput<TStartupState>['runtime']['yomitan']['loadYomitanExtension'];
|
||||
ensureYomitanExtensionLoaded: MainStartupBootstrapInput<TStartupState>['runtime']['yomitan']['ensureYomitanExtensionLoaded'];
|
||||
openYomitanSettings: MainStartupBootstrapInput<TStartupState>['runtime']['yomitan']['openYomitanSettings'];
|
||||
};
|
||||
|
||||
export interface MainStartupRuntimeBootstrapInput<TStartupState> {
|
||||
appState: MainStartupBootstrapInput<TStartupState>['appState'];
|
||||
appLifecycle: {
|
||||
app: MainStartupBootstrapInput<TStartupState>['appLifecycle']['app'];
|
||||
argv: string[];
|
||||
platform: NodeJS.Platform;
|
||||
};
|
||||
config: MainStartupBootstrapInput<TStartupState>['config'];
|
||||
logging: MainStartupBootstrapInput<TStartupState>['logging'];
|
||||
shell: MainStartupBootstrapInput<TStartupState>['shell'];
|
||||
runtime: Omit<MainStartupBootstrapInput<TStartupState>['runtime'], 'overlayUi' | 'yomitan'> & {
|
||||
texthookerService: {
|
||||
isRunning: () => boolean;
|
||||
start: (port: number, websocketUrl?: string) => void;
|
||||
};
|
||||
getOverlayUi: MainStartupBootstrapInput<TStartupState>['runtime']['overlayUi']['get'];
|
||||
getYomitanRuntime: () => StartupBootstrapYomitanRuntime<TStartupState>;
|
||||
getCharacterDictionaryDisabledReason: () => string | null;
|
||||
};
|
||||
commands: Omit<
|
||||
MainStartupBootstrapInput<TStartupState>['commands'],
|
||||
| 'startTexthooker'
|
||||
| 'generateCharacterDictionary'
|
||||
| 'runYoutubePlaybackFlow'
|
||||
| 'getMultiCopyTimeoutMs'
|
||||
> & {
|
||||
getConfiguredShortcuts: () => { multiCopyTimeoutMs: number };
|
||||
runYoutubePlaybackFlow: MainStartupBootstrapInput<TStartupState>['commands']['runYoutubePlaybackFlow'];
|
||||
};
|
||||
constants: MainStartupBootstrapInput<TStartupState>['constants'];
|
||||
}
|
||||
|
||||
export interface MainStartupRuntimeBootstrap<TStartupState> {
|
||||
startupRuntime: MainStartupRuntime<TStartupState>;
|
||||
}
|
||||
|
||||
export interface MainStartupRuntimeFromMainStateInput<TStartupState> {
|
||||
appState: MainStartupRuntimeBootstrapInput<TStartupState>['appState'];
|
||||
appLifecycle: MainStartupRuntimeBootstrapInput<TStartupState>['appLifecycle'];
|
||||
config: MainStartupRuntimeBootstrapInput<TStartupState>['config'];
|
||||
logging: MainStartupRuntimeBootstrapInput<TStartupState>['logging'];
|
||||
shell: MainStartupRuntimeBootstrapInput<TStartupState>['shell'];
|
||||
runtime: {
|
||||
subtitle: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['subtitle'];
|
||||
getOverlayUi: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['getOverlayUi'];
|
||||
overlayManager: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['overlayManager'];
|
||||
firstRun: {
|
||||
ensureSetupStateInitialized: FirstRunRuntime['ensureSetupStateInitialized'];
|
||||
openFirstRunSetupWindow: () => void;
|
||||
};
|
||||
anilist: {
|
||||
refreshAnilistClientSecretStateIfEnabled: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['refreshAnilistClientSecretStateIfEnabled'];
|
||||
openAnilistSetupWindow: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['openAnilistSetupWindow'];
|
||||
getStatusSnapshot: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['getStatusSnapshot'];
|
||||
clearTokenState: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['clearTokenState'];
|
||||
getQueueStatusSnapshot: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['getQueueStatusSnapshot'];
|
||||
processNextAnilistRetryUpdate: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['anilist']['processNextAnilistRetryUpdate'];
|
||||
};
|
||||
jellyfin: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['jellyfin'];
|
||||
stats: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['stats'];
|
||||
mining: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['mining'];
|
||||
texthookerService: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['texthookerService'];
|
||||
yomitan: StartupBootstrapYomitanRuntime<TStartupState>;
|
||||
getCharacterDictionaryDisabledReason: () => string | null;
|
||||
subsyncRuntime: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['subsyncRuntime'];
|
||||
dictionarySupport: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['dictionarySupport'];
|
||||
subtitleWsService: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['subtitleWsService'];
|
||||
annotationSubtitleWsService: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['annotationSubtitleWsService'];
|
||||
immersion: MainStartupRuntimeBootstrapInput<TStartupState>['runtime']['immersion'];
|
||||
};
|
||||
commands: {
|
||||
mpvRuntime: {
|
||||
createMpvClientRuntimeService: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['createMpvClientRuntimeService'];
|
||||
createMecabTokenizerAndCheck: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['createMecabTokenizerAndCheck'];
|
||||
prewarmSubtitleDictionaries: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['prewarmSubtitleDictionaries'];
|
||||
startBackgroundWarmups: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['startBackgroundWarmups'];
|
||||
};
|
||||
runHeadlessInitialCommand: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['runHeadlessInitialCommand'];
|
||||
shortcuts: {
|
||||
startPendingMultiCopy: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['startPendingMultiCopy'];
|
||||
startPendingMineSentenceMultiple: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['startPendingMineSentenceMultiple'];
|
||||
refreshOverlayShortcuts: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['refreshOverlayShortcuts'];
|
||||
getConfiguredShortcuts: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['getConfiguredShortcuts'];
|
||||
};
|
||||
cycleSecondarySubMode: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['cycleSecondarySubMode'];
|
||||
hasMpvWebsocketPlugin: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['hasMpvWebsocketPlugin'];
|
||||
showMpvOsd: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['showMpvOsd'];
|
||||
shouldAutoOpenFirstRunSetup: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldAutoOpenFirstRunSetup'];
|
||||
youtube: {
|
||||
runYoutubePlaybackFlow: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['runYoutubePlaybackFlow'];
|
||||
};
|
||||
shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldEnsureTrayOnStartupForInitialArgs'];
|
||||
isHeadlessInitialCommand: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['isHeadlessInitialCommand'];
|
||||
commandNeedsOverlayStartupPrereqs: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['commandNeedsOverlayStartupPrereqs'];
|
||||
commandNeedsOverlayRuntime: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['commandNeedsOverlayRuntime'];
|
||||
handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['handleCliCommandRuntimeServiceWithContext'];
|
||||
shouldStartApp: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldStartApp'];
|
||||
parseArgs: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['parseArgs'];
|
||||
printHelp: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['printHelp'];
|
||||
onWillQuitCleanupHandler: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['onWillQuitCleanupHandler'];
|
||||
shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['shouldRestoreWindowsOnActivateHandler'];
|
||||
restoreWindowsOnActivateHandler: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['restoreWindowsOnActivateHandler'];
|
||||
forceX11Backend: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['forceX11Backend'];
|
||||
enforceUnsupportedWaylandMode: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['enforceUnsupportedWaylandMode'];
|
||||
getDefaultSocketPath: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['getDefaultSocketPathHandler'];
|
||||
generateDefaultConfigFile: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['generateDefaultConfigFile'];
|
||||
runStartupBootstrapRuntime: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['runStartupBootstrapRuntime'];
|
||||
applyStartupState: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['applyStartupState'];
|
||||
getStartupModeFlags: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['getStartupModeFlags'];
|
||||
requestAppQuit: MainStartupRuntimeBootstrapInput<TStartupState>['commands']['requestAppQuit'];
|
||||
};
|
||||
constants: MainStartupRuntimeBootstrapInput<TStartupState>['constants'];
|
||||
}
|
||||
|
||||
export function createMainStartupRuntimeBootstrap<TStartupState>(
|
||||
input: MainStartupRuntimeBootstrapInput<TStartupState>,
|
||||
): MainStartupRuntimeBootstrap<TStartupState> {
|
||||
const startupRuntime = createMainStartupBootstrap<TStartupState>({
|
||||
appState: input.appState,
|
||||
appLifecycle: {
|
||||
app: input.appLifecycle.app,
|
||||
argv: input.appLifecycle.argv,
|
||||
platform: input.appLifecycle.platform,
|
||||
},
|
||||
config: input.config,
|
||||
logging: input.logging,
|
||||
shell: input.shell,
|
||||
runtime: {
|
||||
...input.runtime,
|
||||
overlayUi: {
|
||||
get: () => input.runtime.getOverlayUi(),
|
||||
},
|
||||
yomitan: {
|
||||
loadYomitanExtension: () => input.runtime.getYomitanRuntime().loadYomitanExtension(),
|
||||
ensureYomitanExtensionLoaded: () =>
|
||||
input.runtime.getYomitanRuntime().ensureYomitanExtensionLoaded(),
|
||||
openYomitanSettings: () => input.runtime.getYomitanRuntime().openYomitanSettings(),
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
...input.commands,
|
||||
startTexthooker: (port, websocketUrl) => {
|
||||
if (!input.runtime.texthookerService.isRunning()) {
|
||||
input.runtime.texthookerService.start(port, websocketUrl);
|
||||
}
|
||||
},
|
||||
generateCharacterDictionary: async (targetPath?: string) => {
|
||||
const disabledReason = input.runtime.getCharacterDictionaryDisabledReason();
|
||||
if (disabledReason) {
|
||||
throw new Error(disabledReason);
|
||||
}
|
||||
return await input.runtime.dictionarySupport.generateCharacterDictionaryForCurrentMedia(
|
||||
targetPath,
|
||||
);
|
||||
},
|
||||
runYoutubePlaybackFlow: (request) => input.commands.runYoutubePlaybackFlow(request),
|
||||
getMultiCopyTimeoutMs: () => input.commands.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
},
|
||||
constants: input.constants,
|
||||
});
|
||||
|
||||
return {
|
||||
startupRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMainStartupRuntimeFromMainState<TStartupState>(
|
||||
input: MainStartupRuntimeFromMainStateInput<TStartupState>,
|
||||
): MainStartupRuntimeBootstrap<TStartupState> {
|
||||
return createMainStartupRuntimeBootstrap<TStartupState>({
|
||||
appState: input.appState,
|
||||
appLifecycle: input.appLifecycle,
|
||||
config: input.config,
|
||||
logging: input.logging,
|
||||
shell: input.shell,
|
||||
runtime: {
|
||||
subtitle: input.runtime.subtitle,
|
||||
getOverlayUi: () => input.runtime.getOverlayUi(),
|
||||
overlayManager: input.runtime.overlayManager,
|
||||
firstRun: {
|
||||
ensureSetupStateInitialized: () => input.runtime.firstRun.ensureSetupStateInitialized(),
|
||||
openFirstRunSetupWindow: () => input.runtime.firstRun.openFirstRunSetupWindow(),
|
||||
},
|
||||
anilist: {
|
||||
refreshAnilistClientSecretStateIfEnabled: (options) =>
|
||||
input.runtime.anilist.refreshAnilistClientSecretStateIfEnabled(options),
|
||||
openAnilistSetupWindow: () => input.runtime.anilist.openAnilistSetupWindow(),
|
||||
getStatusSnapshot: () => input.runtime.anilist.getStatusSnapshot(),
|
||||
clearTokenState: () => input.runtime.anilist.clearTokenState(),
|
||||
getQueueStatusSnapshot: () => input.runtime.anilist.getQueueStatusSnapshot(),
|
||||
processNextAnilistRetryUpdate: () => input.runtime.anilist.processNextAnilistRetryUpdate(),
|
||||
},
|
||||
jellyfin: input.runtime.jellyfin,
|
||||
stats: input.runtime.stats,
|
||||
mining: input.runtime.mining,
|
||||
texthookerService: input.runtime.texthookerService,
|
||||
getYomitanRuntime: () => input.runtime.yomitan,
|
||||
getCharacterDictionaryDisabledReason: () =>
|
||||
input.runtime.getCharacterDictionaryDisabledReason(),
|
||||
subsyncRuntime: input.runtime.subsyncRuntime,
|
||||
dictionarySupport: input.runtime.dictionarySupport,
|
||||
subtitleWsService: input.runtime.subtitleWsService,
|
||||
annotationSubtitleWsService: input.runtime.annotationSubtitleWsService,
|
||||
immersion: input.runtime.immersion,
|
||||
},
|
||||
commands: {
|
||||
createMpvClientRuntimeService: () =>
|
||||
input.commands.mpvRuntime.createMpvClientRuntimeService(),
|
||||
createMecabTokenizerAndCheck: () => input.commands.mpvRuntime.createMecabTokenizerAndCheck(),
|
||||
prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(),
|
||||
startBackgroundWarmupsIfAllowed: () => input.commands.mpvRuntime.startBackgroundWarmups(),
|
||||
startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(),
|
||||
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
|
||||
startPendingMultiCopy: (timeoutMs) =>
|
||||
input.commands.shortcuts.startPendingMultiCopy(timeoutMs),
|
||||
startPendingMineSentenceMultiple: (timeoutMs) =>
|
||||
input.commands.shortcuts.startPendingMineSentenceMultiple(timeoutMs),
|
||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
||||
refreshOverlayShortcuts: () => input.commands.shortcuts.refreshOverlayShortcuts(),
|
||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
||||
showMpvOsd: (text) => input.commands.showMpvOsd(text),
|
||||
shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args),
|
||||
getConfiguredShortcuts: () => input.commands.shortcuts.getConfiguredShortcuts(),
|
||||
runYoutubePlaybackFlow: (request) => input.commands.youtube.runYoutubePlaybackFlow(request),
|
||||
shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) =>
|
||||
input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs),
|
||||
isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
|
||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
||||
input.commands.commandNeedsOverlayStartupPrereqs(args),
|
||||
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
|
||||
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
||||
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
||||
shouldStartApp: (args) => input.commands.shouldStartApp(args),
|
||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
||||
printHelp: input.commands.printHelp,
|
||||
onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivateHandler: () =>
|
||||
input.commands.shouldRestoreWindowsOnActivateHandler(),
|
||||
restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(),
|
||||
forceX11Backend: (args) => input.commands.forceX11Backend(args),
|
||||
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
|
||||
getDefaultSocketPathHandler: () => input.commands.getDefaultSocketPath(),
|
||||
generateDefaultConfigFile: input.commands.generateDefaultConfigFile,
|
||||
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
|
||||
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
|
||||
getStartupModeFlags: input.commands.getStartupModeFlags,
|
||||
requestAppQuit: input.commands.requestAppQuit,
|
||||
},
|
||||
constants: input.constants,
|
||||
});
|
||||
}
|
||||
370
src/main/main-startup-runtime-coordinator.ts
Normal file
370
src/main/main-startup-runtime-coordinator.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import type { AnilistRuntime } from './anilist-runtime';
|
||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
||||
import type { FirstRunRuntime } from './first-run-runtime';
|
||||
import type { JellyfinRuntime } from './jellyfin-runtime';
|
||||
import {
|
||||
createMainStartupRuntimeFromMainState,
|
||||
type MainStartupRuntimeBootstrap,
|
||||
type MainStartupRuntimeFromMainStateInput,
|
||||
} from './main-startup-runtime-bootstrap';
|
||||
import type { MiningRuntime } from './mining-runtime';
|
||||
import type { MpvRuntime } from './mpv-runtime';
|
||||
import type { ShortcutsRuntime } from './shortcuts-runtime';
|
||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
||||
import type { YoutubeRuntime } from './youtube-runtime';
|
||||
|
||||
export interface MainStartupRuntimeCoordinatorInput<TStartupState> {
|
||||
appState: MainStartupRuntimeFromMainStateInput<TStartupState>['appState'];
|
||||
appLifecycle: MainStartupRuntimeFromMainStateInput<TStartupState>['appLifecycle'];
|
||||
config: MainStartupRuntimeFromMainStateInput<TStartupState>['config'];
|
||||
logging: MainStartupRuntimeFromMainStateInput<TStartupState>['logging'];
|
||||
shell: MainStartupRuntimeFromMainStateInput<TStartupState>['shell'];
|
||||
runtime: {
|
||||
subtitle: SubtitleRuntime;
|
||||
getOverlayUi: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['getOverlayUi'];
|
||||
overlayManager: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['overlayManager'];
|
||||
firstRun: Pick<FirstRunRuntime, 'ensureSetupStateInitialized' | 'openFirstRunSetupWindow'>;
|
||||
anilist: AnilistRuntime;
|
||||
jellyfin: JellyfinRuntime;
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['stats']['ensureImmersionTrackerStarted'];
|
||||
runStatsCliCommand: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['stats']['runStatsCliCommand'];
|
||||
immersion: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['immersion'];
|
||||
};
|
||||
mining: {
|
||||
copyCurrentSubtitle: Pick<MiningRuntime, 'copyCurrentSubtitle'>['copyCurrentSubtitle'];
|
||||
markLastCardAsAudioCard: Pick<
|
||||
MiningRuntime,
|
||||
'markLastCardAsAudioCard'
|
||||
>['markLastCardAsAudioCard'];
|
||||
mineSentenceCard: Pick<MiningRuntime, 'mineSentenceCard'>['mineSentenceCard'];
|
||||
refreshKnownWordCache: Pick<MiningRuntime, 'refreshKnownWordCache'>['refreshKnownWordCache'];
|
||||
triggerFieldGrouping: Pick<MiningRuntime, 'triggerFieldGrouping'>['triggerFieldGrouping'];
|
||||
updateLastCardFromClipboard: Pick<
|
||||
MiningRuntime,
|
||||
'updateLastCardFromClipboard'
|
||||
>['updateLastCardFromClipboard'];
|
||||
};
|
||||
texthookerService: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['texthookerService'];
|
||||
yomitan: {
|
||||
loadYomitanExtension: () => Promise<unknown>;
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
openYomitanSettings: () => boolean;
|
||||
getCharacterDictionaryDisabledReason: () => string | null;
|
||||
};
|
||||
subsyncRuntime: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['subsyncRuntime'];
|
||||
dictionarySupport: DictionarySupportRuntime;
|
||||
subtitleWsService: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['subtitleWsService'];
|
||||
annotationSubtitleWsService: MainStartupRuntimeFromMainStateInput<TStartupState>['runtime']['annotationSubtitleWsService'];
|
||||
};
|
||||
commands: {
|
||||
mpvRuntime: {
|
||||
createMpvClientRuntimeService: Pick<
|
||||
MpvRuntime,
|
||||
'createMpvClientRuntimeService'
|
||||
>['createMpvClientRuntimeService'];
|
||||
createMecabTokenizerAndCheck: Pick<
|
||||
MpvRuntime,
|
||||
'createMecabTokenizerAndCheck'
|
||||
>['createMecabTokenizerAndCheck'];
|
||||
prewarmSubtitleDictionaries: Pick<
|
||||
MpvRuntime,
|
||||
'prewarmSubtitleDictionaries'
|
||||
>['prewarmSubtitleDictionaries'];
|
||||
startBackgroundWarmups: Pick<MpvRuntime, 'startBackgroundWarmups'>['startBackgroundWarmups'];
|
||||
};
|
||||
runHeadlessInitialCommand: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['runHeadlessInitialCommand'];
|
||||
shortcuts: Pick<
|
||||
ShortcutsRuntime,
|
||||
| 'getConfiguredShortcuts'
|
||||
| 'refreshOverlayShortcuts'
|
||||
| 'startPendingMineSentenceMultiple'
|
||||
| 'startPendingMultiCopy'
|
||||
>;
|
||||
cycleSecondarySubMode: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['cycleSecondarySubMode'];
|
||||
hasMpvWebsocketPlugin: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['hasMpvWebsocketPlugin'];
|
||||
showMpvOsd: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['showMpvOsd'];
|
||||
shouldAutoOpenFirstRunSetup: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldAutoOpenFirstRunSetup'];
|
||||
youtube: Pick<YoutubeRuntime, 'runYoutubePlaybackFlow'>;
|
||||
shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldEnsureTrayOnStartupForInitialArgs'];
|
||||
isHeadlessInitialCommand: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['isHeadlessInitialCommand'];
|
||||
commandNeedsOverlayStartupPrereqs: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['commandNeedsOverlayStartupPrereqs'];
|
||||
commandNeedsOverlayRuntime: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['commandNeedsOverlayRuntime'];
|
||||
handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['handleCliCommandRuntimeServiceWithContext'];
|
||||
shouldStartApp: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldStartApp'];
|
||||
parseArgs: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['parseArgs'];
|
||||
printHelp: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['printHelp'];
|
||||
onWillQuitCleanupHandler: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['onWillQuitCleanupHandler'];
|
||||
shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['shouldRestoreWindowsOnActivateHandler'];
|
||||
restoreWindowsOnActivateHandler: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['restoreWindowsOnActivateHandler'];
|
||||
forceX11Backend: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['forceX11Backend'];
|
||||
enforceUnsupportedWaylandMode: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['enforceUnsupportedWaylandMode'];
|
||||
getDefaultSocketPath: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['getDefaultSocketPath'];
|
||||
generateDefaultConfigFile: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['generateDefaultConfigFile'];
|
||||
runStartupBootstrapRuntime: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['runStartupBootstrapRuntime'];
|
||||
applyStartupState: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['applyStartupState'];
|
||||
getStartupModeFlags: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['getStartupModeFlags'];
|
||||
requestAppQuit: MainStartupRuntimeFromMainStateInput<TStartupState>['commands']['requestAppQuit'];
|
||||
};
|
||||
constants: MainStartupRuntimeFromMainStateInput<TStartupState>['constants'];
|
||||
}
|
||||
|
||||
export interface MainStartupRuntimeFromProcessStateInput<TStartupState> {
|
||||
appState: MainStartupRuntimeCoordinatorInput<TStartupState>['appState'];
|
||||
appLifecycle: MainStartupRuntimeCoordinatorInput<TStartupState>['appLifecycle'];
|
||||
config: MainStartupRuntimeCoordinatorInput<TStartupState>['config'];
|
||||
logging: MainStartupRuntimeCoordinatorInput<TStartupState>['logging'];
|
||||
shell: MainStartupRuntimeCoordinatorInput<TStartupState>['shell'];
|
||||
runtime: {
|
||||
subtitle: SubtitleRuntime;
|
||||
startupOverlayUiAdapter: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['getOverlayUi'] extends () => infer T
|
||||
? T
|
||||
: never;
|
||||
overlayManager: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['overlayManager'];
|
||||
firstRun: Pick<FirstRunRuntime, 'ensureSetupStateInitialized' | 'openFirstRunSetupWindow'>;
|
||||
anilist: AnilistRuntime;
|
||||
jellyfin: JellyfinRuntime;
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['stats']['ensureImmersionTrackerStarted'];
|
||||
runStatsCliCommand: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['stats']['runStatsCliCommand'];
|
||||
immersion: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['stats']['immersion'];
|
||||
};
|
||||
mining: MiningRuntime;
|
||||
texthookerService: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['texthookerService'];
|
||||
yomitan: {
|
||||
loadYomitanExtension: () => Promise<unknown>;
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
openYomitanSettings: () => boolean;
|
||||
getCharacterDictionaryDisabledReason: () => string | null;
|
||||
};
|
||||
subsyncRuntime: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['subsyncRuntime'];
|
||||
dictionarySupport: DictionarySupportRuntime;
|
||||
subtitleWsService: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['subtitleWsService'];
|
||||
annotationSubtitleWsService: MainStartupRuntimeCoordinatorInput<TStartupState>['runtime']['annotationSubtitleWsService'];
|
||||
};
|
||||
commands: {
|
||||
mpvRuntime: MpvRuntime;
|
||||
runHeadlessInitialCommand: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['runHeadlessInitialCommand'];
|
||||
shortcuts: Pick<
|
||||
ShortcutsRuntime,
|
||||
| 'getConfiguredShortcuts'
|
||||
| 'refreshOverlayShortcuts'
|
||||
| 'startPendingMineSentenceMultiple'
|
||||
| 'startPendingMultiCopy'
|
||||
>;
|
||||
cycleSecondarySubMode: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['cycleSecondarySubMode'];
|
||||
hasMpvWebsocketPlugin: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['hasMpvWebsocketPlugin'];
|
||||
showMpvOsd: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['showMpvOsd'];
|
||||
shouldAutoOpenFirstRunSetup: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldAutoOpenFirstRunSetup'];
|
||||
youtube: YoutubeRuntime;
|
||||
shouldEnsureTrayOnStartupForInitialArgs: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldEnsureTrayOnStartupForInitialArgs'];
|
||||
isHeadlessInitialCommand: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['isHeadlessInitialCommand'];
|
||||
commandNeedsOverlayStartupPrereqs: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['commandNeedsOverlayStartupPrereqs'];
|
||||
commandNeedsOverlayRuntime: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['commandNeedsOverlayRuntime'];
|
||||
handleCliCommandRuntimeServiceWithContext: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['handleCliCommandRuntimeServiceWithContext'];
|
||||
shouldStartApp: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldStartApp'];
|
||||
parseArgs: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['parseArgs'];
|
||||
printHelp: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['printHelp'];
|
||||
onWillQuitCleanupHandler: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['onWillQuitCleanupHandler'];
|
||||
shouldRestoreWindowsOnActivateHandler: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['shouldRestoreWindowsOnActivateHandler'];
|
||||
restoreWindowsOnActivateHandler: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['restoreWindowsOnActivateHandler'];
|
||||
forceX11Backend: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['forceX11Backend'];
|
||||
enforceUnsupportedWaylandMode: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['enforceUnsupportedWaylandMode'];
|
||||
getDefaultSocketPath: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['getDefaultSocketPath'];
|
||||
generateDefaultConfigFile: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['generateDefaultConfigFile'];
|
||||
runStartupBootstrapRuntime: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['runStartupBootstrapRuntime'];
|
||||
applyStartupState: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['applyStartupState'];
|
||||
getStartupModeFlags: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['getStartupModeFlags'];
|
||||
requestAppQuit: MainStartupRuntimeCoordinatorInput<TStartupState>['commands']['requestAppQuit'];
|
||||
};
|
||||
constants: MainStartupRuntimeCoordinatorInput<TStartupState>['constants'];
|
||||
}
|
||||
|
||||
export function createMainStartupRuntimeCoordinator<TStartupState>(
|
||||
input: MainStartupRuntimeCoordinatorInput<TStartupState>,
|
||||
): MainStartupRuntimeBootstrap<TStartupState> {
|
||||
return createMainStartupRuntimeFromMainState<TStartupState>({
|
||||
appState: input.appState,
|
||||
appLifecycle: input.appLifecycle,
|
||||
config: input.config,
|
||||
logging: input.logging,
|
||||
shell: input.shell,
|
||||
runtime: {
|
||||
subtitle: {
|
||||
loadSubtitlePosition: () => input.runtime.subtitle.loadSubtitlePosition(),
|
||||
invalidateTokenizationCache: () => {
|
||||
input.runtime.subtitle.invalidateTokenizationCache();
|
||||
},
|
||||
refreshSubtitlePrefetchFromActiveTrack: () =>
|
||||
input.runtime.subtitle.refreshSubtitlePrefetchFromActiveTrack(),
|
||||
},
|
||||
getOverlayUi: () => input.runtime.getOverlayUi(),
|
||||
overlayManager: input.runtime.overlayManager,
|
||||
firstRun: input.runtime.firstRun,
|
||||
anilist: input.runtime.anilist,
|
||||
jellyfin: {
|
||||
startJellyfinRemoteSession: () => input.runtime.jellyfin.startJellyfinRemoteSession(),
|
||||
openJellyfinSetupWindow: () => input.runtime.jellyfin.openJellyfinSetupWindow(),
|
||||
runJellyfinCommand: (argsFromCommand) =>
|
||||
input.runtime.jellyfin.runJellyfinCommand(argsFromCommand),
|
||||
},
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => input.runtime.stats.ensureImmersionTrackerStarted(),
|
||||
runStatsCliCommand: (argsFromCommand, source) =>
|
||||
input.runtime.stats.runStatsCliCommand(argsFromCommand, source),
|
||||
},
|
||||
mining: {
|
||||
copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(),
|
||||
mineSentenceCard: () => input.runtime.mining.mineSentenceCard(),
|
||||
updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(),
|
||||
refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(),
|
||||
triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(),
|
||||
markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(),
|
||||
},
|
||||
texthookerService: input.runtime.texthookerService,
|
||||
yomitan: {
|
||||
loadYomitanExtension: () => input.runtime.yomitan.loadYomitanExtension(),
|
||||
ensureYomitanExtensionLoaded: () => input.runtime.yomitan.ensureYomitanExtensionLoaded(),
|
||||
openYomitanSettings: () => input.runtime.yomitan.openYomitanSettings(),
|
||||
},
|
||||
getCharacterDictionaryDisabledReason: () =>
|
||||
input.runtime.yomitan.getCharacterDictionaryDisabledReason(),
|
||||
subsyncRuntime: input.runtime.subsyncRuntime,
|
||||
dictionarySupport: input.runtime.dictionarySupport,
|
||||
subtitleWsService: input.runtime.subtitleWsService,
|
||||
annotationSubtitleWsService: input.runtime.annotationSubtitleWsService,
|
||||
immersion: input.runtime.stats.immersion,
|
||||
},
|
||||
commands: {
|
||||
mpvRuntime: {
|
||||
createMpvClientRuntimeService: () =>
|
||||
input.commands.mpvRuntime.createMpvClientRuntimeService(),
|
||||
createMecabTokenizerAndCheck: () =>
|
||||
input.commands.mpvRuntime.createMecabTokenizerAndCheck(),
|
||||
prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(),
|
||||
startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(),
|
||||
},
|
||||
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
|
||||
shortcuts: {
|
||||
startPendingMultiCopy: (timeoutMs) =>
|
||||
input.commands.shortcuts.startPendingMultiCopy(timeoutMs),
|
||||
startPendingMineSentenceMultiple: (timeoutMs) =>
|
||||
input.commands.shortcuts.startPendingMineSentenceMultiple(timeoutMs),
|
||||
refreshOverlayShortcuts: () => input.commands.shortcuts.refreshOverlayShortcuts(),
|
||||
getConfiguredShortcuts: () => input.commands.shortcuts.getConfiguredShortcuts(),
|
||||
},
|
||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
||||
showMpvOsd: (text) => input.commands.showMpvOsd(text),
|
||||
shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args),
|
||||
youtube: {
|
||||
runYoutubePlaybackFlow: (request) => input.commands.youtube.runYoutubePlaybackFlow(request),
|
||||
},
|
||||
shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) =>
|
||||
input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs ?? null),
|
||||
isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
|
||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
||||
input.commands.commandNeedsOverlayStartupPrereqs(args),
|
||||
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
|
||||
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
||||
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
||||
shouldStartApp: (args) => input.commands.shouldStartApp(args),
|
||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
||||
printHelp: input.commands.printHelp,
|
||||
onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivateHandler: () =>
|
||||
input.commands.shouldRestoreWindowsOnActivateHandler(),
|
||||
restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(),
|
||||
forceX11Backend: (args) => input.commands.forceX11Backend(args),
|
||||
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
|
||||
getDefaultSocketPath: () => input.commands.getDefaultSocketPath(),
|
||||
generateDefaultConfigFile: input.commands.generateDefaultConfigFile,
|
||||
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
|
||||
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
|
||||
getStartupModeFlags: input.commands.getStartupModeFlags,
|
||||
requestAppQuit: input.commands.requestAppQuit,
|
||||
},
|
||||
constants: input.constants,
|
||||
});
|
||||
}
|
||||
|
||||
export function createMainStartupRuntimeFromProcessState<TStartupState>(
|
||||
input: MainStartupRuntimeFromProcessStateInput<TStartupState>,
|
||||
) {
|
||||
return createMainStartupRuntimeCoordinator<TStartupState>({
|
||||
appState: input.appState,
|
||||
appLifecycle: input.appLifecycle,
|
||||
config: input.config,
|
||||
logging: input.logging,
|
||||
shell: input.shell,
|
||||
runtime: {
|
||||
subtitle: input.runtime.subtitle,
|
||||
getOverlayUi: () => input.runtime.startupOverlayUiAdapter,
|
||||
overlayManager: input.runtime.overlayManager,
|
||||
firstRun: input.runtime.firstRun,
|
||||
anilist: input.runtime.anilist,
|
||||
jellyfin: input.runtime.jellyfin,
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => input.runtime.stats.ensureImmersionTrackerStarted(),
|
||||
runStatsCliCommand: (argsFromCommand, source) =>
|
||||
input.runtime.stats.runStatsCliCommand(argsFromCommand, source),
|
||||
immersion: input.runtime.stats.immersion,
|
||||
},
|
||||
mining: {
|
||||
copyCurrentSubtitle: () => input.runtime.mining.copyCurrentSubtitle(),
|
||||
markLastCardAsAudioCard: () => input.runtime.mining.markLastCardAsAudioCard(),
|
||||
mineSentenceCard: () => input.runtime.mining.mineSentenceCard(),
|
||||
refreshKnownWordCache: () => input.runtime.mining.refreshKnownWordCache(),
|
||||
triggerFieldGrouping: () => input.runtime.mining.triggerFieldGrouping(),
|
||||
updateLastCardFromClipboard: () => input.runtime.mining.updateLastCardFromClipboard(),
|
||||
},
|
||||
texthookerService: input.runtime.texthookerService,
|
||||
yomitan: input.runtime.yomitan,
|
||||
subsyncRuntime: input.runtime.subsyncRuntime,
|
||||
dictionarySupport: input.runtime.dictionarySupport,
|
||||
subtitleWsService: input.runtime.subtitleWsService,
|
||||
annotationSubtitleWsService: input.runtime.annotationSubtitleWsService,
|
||||
},
|
||||
commands: {
|
||||
mpvRuntime: {
|
||||
createMpvClientRuntimeService: () =>
|
||||
input.commands.mpvRuntime.createMpvClientRuntimeService(),
|
||||
createMecabTokenizerAndCheck: () =>
|
||||
input.commands.mpvRuntime.createMecabTokenizerAndCheck(),
|
||||
prewarmSubtitleDictionaries: () => input.commands.mpvRuntime.prewarmSubtitleDictionaries(),
|
||||
startBackgroundWarmups: () => input.commands.mpvRuntime.startBackgroundWarmups(),
|
||||
},
|
||||
runHeadlessInitialCommand: () => input.commands.runHeadlessInitialCommand(),
|
||||
shortcuts: input.commands.shortcuts,
|
||||
cycleSecondarySubMode: () => input.commands.cycleSecondarySubMode(),
|
||||
hasMpvWebsocketPlugin: () => input.commands.hasMpvWebsocketPlugin(),
|
||||
showMpvOsd: (text) => input.commands.showMpvOsd(text),
|
||||
shouldAutoOpenFirstRunSetup: (args) => input.commands.shouldAutoOpenFirstRunSetup(args),
|
||||
youtube: input.commands.youtube,
|
||||
shouldEnsureTrayOnStartupForInitialArgs: (platform, initialArgs) =>
|
||||
input.commands.shouldEnsureTrayOnStartupForInitialArgs(platform, initialArgs ?? null),
|
||||
isHeadlessInitialCommand: (args) => input.commands.isHeadlessInitialCommand(args),
|
||||
commandNeedsOverlayStartupPrereqs: (args) =>
|
||||
input.commands.commandNeedsOverlayStartupPrereqs(args),
|
||||
commandNeedsOverlayRuntime: (args) => input.commands.commandNeedsOverlayRuntime(args),
|
||||
handleCliCommandRuntimeServiceWithContext: (args, source, cliContext) =>
|
||||
input.commands.handleCliCommandRuntimeServiceWithContext(args, source, cliContext),
|
||||
shouldStartApp: (args) => input.commands.shouldStartApp(args),
|
||||
parseArgs: (argv) => input.commands.parseArgs(argv),
|
||||
printHelp: input.commands.printHelp,
|
||||
onWillQuitCleanupHandler: () => input.commands.onWillQuitCleanupHandler(),
|
||||
shouldRestoreWindowsOnActivateHandler: () =>
|
||||
input.commands.shouldRestoreWindowsOnActivateHandler(),
|
||||
restoreWindowsOnActivateHandler: () => input.commands.restoreWindowsOnActivateHandler(),
|
||||
forceX11Backend: (args) => input.commands.forceX11Backend(args),
|
||||
enforceUnsupportedWaylandMode: (args) => input.commands.enforceUnsupportedWaylandMode(args),
|
||||
getDefaultSocketPath: () => input.commands.getDefaultSocketPath(),
|
||||
generateDefaultConfigFile: input.commands.generateDefaultConfigFile,
|
||||
runStartupBootstrapRuntime: (deps) => input.commands.runStartupBootstrapRuntime(deps),
|
||||
applyStartupState: (startupState) => input.commands.applyStartupState(startupState),
|
||||
getStartupModeFlags: input.commands.getStartupModeFlags,
|
||||
requestAppQuit: input.commands.requestAppQuit,
|
||||
},
|
||||
constants: input.constants,
|
||||
}).startupRuntime;
|
||||
}
|
||||
260
src/main/main-startup-runtime.test.ts
Normal file
260
src/main/main-startup-runtime.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createMainStartupRuntime } from './main-startup-runtime';
|
||||
|
||||
test('main startup runtime composes app-ready, cli, and headless runtimes', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createMainStartupRuntime<{ mode: string }>({
|
||||
appReady: {
|
||||
reload: {
|
||||
reloadConfigStrict: () => ({ ok: true as const, path: '/tmp/config.jsonc', warnings: [] }),
|
||||
logInfo: () => {},
|
||||
logWarning: () => {},
|
||||
showDesktopNotification: () => {},
|
||||
startConfigHotReload: () => {},
|
||||
refreshAnilistClientSecretState: async () => undefined,
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {},
|
||||
},
|
||||
},
|
||||
criticalConfig: {
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
failHandlers: {
|
||||
logError: () => {},
|
||||
showErrorBox: () => {},
|
||||
quit: () => {},
|
||||
},
|
||||
},
|
||||
immersion: {
|
||||
getResolvedConfig: () => ({ immersionTracking: { enabled: false } }) as never,
|
||||
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
|
||||
createTrackerService: () => ({}) as never,
|
||||
setTracker: () => {},
|
||||
getMpvClient: () => null,
|
||||
seedTrackerFromCurrentMedia: () => {},
|
||||
logInfo: () => {},
|
||||
logDebug: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
runner: {
|
||||
ensureDefaultConfigBootstrap: () => {
|
||||
calls.push('ensureDefaultConfigBootstrap');
|
||||
},
|
||||
getSubtitlePosition: () => null,
|
||||
loadSubtitlePosition: () => {
|
||||
calls.push('loadSubtitlePosition');
|
||||
},
|
||||
getKeybindingsCount: () => 0,
|
||||
resolveKeybindings: () => {
|
||||
calls.push('resolveKeybindings');
|
||||
},
|
||||
hasMpvClient: () => false,
|
||||
createMpvClient: () => {
|
||||
calls.push('createMpvClient');
|
||||
},
|
||||
getRuntimeOptionsManager: () => null,
|
||||
initRuntimeOptionsManager: () => {
|
||||
calls.push('initRuntimeOptionsManager');
|
||||
},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
createSubtitleTimingTracker: () => {
|
||||
calls.push('createSubtitleTimingTracker');
|
||||
},
|
||||
getResolvedConfig: () => ({ ankiConnect: { enabled: false } }) as never,
|
||||
getConfigWarnings: () => [],
|
||||
logConfigWarning: () => {},
|
||||
setLogLevel: () => {},
|
||||
setSecondarySubMode: () => {},
|
||||
defaultSecondarySubMode: 'hover',
|
||||
defaultWebsocketPort: 5174,
|
||||
defaultAnnotationWebsocketPort: 6678,
|
||||
defaultTexthookerPort: 5174,
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
startSubtitleWebsocket: () => {},
|
||||
startAnnotationWebsocket: () => {},
|
||||
startTexthooker: () => {},
|
||||
log: () => {},
|
||||
createMecabTokenizerAndCheck: async () => {},
|
||||
loadYomitanExtension: async () => {},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
calls.push('ensureYomitanExtensionLoaded');
|
||||
},
|
||||
handleFirstRunSetup: async () => {},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startBackgroundWarmups');
|
||||
},
|
||||
texthookerOnlyMode: false,
|
||||
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
initializeOverlayRuntime: () => {
|
||||
calls.push('initializeOverlayRuntime');
|
||||
},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {
|
||||
calls.push('ensureOverlayWindowsReadyForVisibilityActions');
|
||||
},
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
},
|
||||
cli: {
|
||||
appState: {
|
||||
appState: {} as never,
|
||||
getInitialArgs: () => null,
|
||||
isBackgroundMode: () => false,
|
||||
isTexthookerOnlyMode: () => false,
|
||||
setTexthookerOnlyMode: () => {},
|
||||
hasImmersionTracker: () => false,
|
||||
getMpvClient: () => null,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
},
|
||||
config: {
|
||||
defaultConfig: { websocket: { port: 6677 }, annotationWebsocket: { port: 6678 } } as never,
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
setCliLogLevel: () => {},
|
||||
hasMpvWebsocketPlugin: () => false,
|
||||
},
|
||||
io: {
|
||||
texthookerService: {} as never,
|
||||
openExternal: async () => {},
|
||||
logBrowserOpenError: () => {},
|
||||
showMpvOsd: () => {},
|
||||
schedule: () => 0 as never,
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
},
|
||||
commands: {
|
||||
initializeOverlayRuntime: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
openFirstRunSetupWindow: () => {},
|
||||
setVisibleOverlayVisible: () => {},
|
||||
copyCurrentSubtitle: () => {},
|
||||
startPendingMultiCopy: () => {},
|
||||
mineSentenceCard: async () => {},
|
||||
startPendingMineSentenceMultiple: () => {},
|
||||
updateLastCardFromClipboard: async () => {},
|
||||
refreshKnownWordCache: async () => {},
|
||||
triggerFieldGrouping: async () => {},
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
getAnilistStatus: () => ({}) as never,
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetupWindow: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
|
||||
generateCharacterDictionary: async () => ({
|
||||
zipPath: '/tmp/test.zip',
|
||||
fromCache: false,
|
||||
mediaId: 1,
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 1,
|
||||
}),
|
||||
runJellyfinCommand: async () => {},
|
||||
runStatsCommand: async () => {},
|
||||
runYoutubePlaybackFlow: async () => {},
|
||||
openYomitanSettings: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
stopApp: () => {
|
||||
calls.push('stopApp');
|
||||
},
|
||||
hasMainWindow: () => false,
|
||||
getMultiCopyTimeoutMs: () => 0,
|
||||
},
|
||||
startup: {
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
shouldRunHeadlessInitialCommand: () => false,
|
||||
ensureTray: () => {},
|
||||
commandNeedsOverlayStartupPrereqs: () => false,
|
||||
commandNeedsOverlayRuntime: () => false,
|
||||
ensureOverlayStartupPrereqs: () => {
|
||||
calls.push('ensureOverlayStartupPrereqs');
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startupStartBackgroundWarmups');
|
||||
},
|
||||
},
|
||||
handleCliCommandRuntimeServiceWithContext: (args) => {
|
||||
calls.push(`handle:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
},
|
||||
headless: {
|
||||
appLifecycleRuntimeRunnerMainDeps: {
|
||||
app: { on: () => {} } as never,
|
||||
platform: 'darwin',
|
||||
shouldStartApp: () => true,
|
||||
parseArgs: () => ({}) as never,
|
||||
handleCliCommand: () => {},
|
||||
printHelp: () => {},
|
||||
logNoRunningInstance: () => {},
|
||||
onReady: async () => {},
|
||||
onWillQuitCleanup: () => {},
|
||||
shouldRestoreWindowsOnActivate: () => false,
|
||||
restoreWindowsOnActivate: () => {},
|
||||
shouldQuitOnWindowAllClosed: () => false,
|
||||
},
|
||||
createAppLifecycleRuntimeRunner: () => (args) => {
|
||||
calls.push(`lifecycle:${(args as { command?: string }).command ?? 'unknown'}`);
|
||||
},
|
||||
bootstrap: {
|
||||
argv: ['node', 'main.js'],
|
||||
parseArgs: () => ({ command: 'start' }) as never,
|
||||
setLogLevel: () => {},
|
||||
forceX11Backend: () => {},
|
||||
enforceUnsupportedWaylandMode: () => {},
|
||||
shouldStartApp: () => true,
|
||||
getDefaultSocketPath: () => '/tmp/mpv.sock',
|
||||
defaultTexthookerPort: 5174,
|
||||
configDir: '/tmp/config',
|
||||
defaultConfig: {} as never,
|
||||
generateConfigTemplate: () => 'template',
|
||||
generateDefaultConfigFile: async () => 0,
|
||||
setExitCode: () => {},
|
||||
quitApp: () => {},
|
||||
logGenerateConfigError: () => {},
|
||||
startAppLifecycle: () => {},
|
||||
},
|
||||
runStartupBootstrapRuntime: (deps) => {
|
||||
deps.startAppLifecycle({ command: 'start' } as never);
|
||||
return { mode: 'started' };
|
||||
},
|
||||
applyStartupState: (state: { mode: string }) => {
|
||||
calls.push(`apply:${state.mode}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof runtime.appReady.runAppReady, 'function');
|
||||
assert.equal(typeof runtime.cliStartup.handleCliCommand, 'function');
|
||||
assert.equal(typeof runtime.headlessStartup.runAndApplyStartupState, 'function');
|
||||
assert.equal(runtime.handleCliCommand, runtime.cliStartup.handleCliCommand);
|
||||
assert.equal(runtime.handleInitialArgs, runtime.cliStartup.handleInitialArgs);
|
||||
assert.equal(
|
||||
runtime.appLifecycleRuntimeRunner,
|
||||
runtime.headlessStartup.appLifecycleRuntimeRunner,
|
||||
);
|
||||
assert.equal(runtime.runAndApplyStartupState, runtime.headlessStartup.runAndApplyStartupState);
|
||||
|
||||
runtime.appReady.ensureOverlayStartupPrereqs();
|
||||
runtime.handleCliCommand({ command: 'start' } as never);
|
||||
assert.deepEqual(runtime.runAndApplyStartupState(), { mode: 'started' });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'loadSubtitlePosition',
|
||||
'resolveKeybindings',
|
||||
'createMpvClient',
|
||||
'initRuntimeOptionsManager',
|
||||
'createSubtitleTimingTracker',
|
||||
'handle:start',
|
||||
'lifecycle:start',
|
||||
'apply:started',
|
||||
]);
|
||||
});
|
||||
43
src/main/main-startup-runtime.ts
Normal file
43
src/main/main-startup-runtime.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { AppReadyRuntimeInput, AppReadyRuntime } from './app-ready-runtime';
|
||||
import type { CliStartupRuntimeInput, CliStartupRuntime } from './cli-startup-runtime';
|
||||
import type {
|
||||
HeadlessStartupRuntimeInput,
|
||||
HeadlessStartupRuntime,
|
||||
} from './headless-startup-runtime';
|
||||
import { createAppReadyRuntime } from './app-ready-runtime';
|
||||
import { createCliStartupRuntime } from './cli-startup-runtime';
|
||||
import { createHeadlessStartupRuntime } from './headless-startup-runtime';
|
||||
|
||||
export interface MainStartupRuntimeInput<TStartupState> {
|
||||
appReady: AppReadyRuntimeInput;
|
||||
cli: CliStartupRuntimeInput;
|
||||
headless: HeadlessStartupRuntimeInput<TStartupState>;
|
||||
}
|
||||
|
||||
export interface MainStartupRuntime<TStartupState> {
|
||||
appReady: AppReadyRuntime;
|
||||
cliStartup: CliStartupRuntime;
|
||||
headlessStartup: HeadlessStartupRuntime<TStartupState>;
|
||||
handleCliCommand: CliStartupRuntime['handleCliCommand'];
|
||||
handleInitialArgs: CliStartupRuntime['handleInitialArgs'];
|
||||
appLifecycleRuntimeRunner: HeadlessStartupRuntime<TStartupState>['appLifecycleRuntimeRunner'];
|
||||
runAndApplyStartupState: HeadlessStartupRuntime<TStartupState>['runAndApplyStartupState'];
|
||||
}
|
||||
|
||||
export function createMainStartupRuntime<TStartupState>(
|
||||
input: MainStartupRuntimeInput<TStartupState>,
|
||||
): MainStartupRuntime<TStartupState> {
|
||||
const appReady = createAppReadyRuntime(input.appReady);
|
||||
const cliStartup = createCliStartupRuntime(input.cli);
|
||||
const headlessStartup = createHeadlessStartupRuntime<TStartupState>(input.headless);
|
||||
|
||||
return {
|
||||
appReady,
|
||||
cliStartup,
|
||||
headlessStartup,
|
||||
handleCliCommand: cliStartup.handleCliCommand,
|
||||
handleInitialArgs: cliStartup.handleInitialArgs,
|
||||
appLifecycleRuntimeRunner: headlessStartup.appLifecycleRuntimeRunner,
|
||||
runAndApplyStartupState: headlessStartup.runAndApplyStartupState,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { updateCurrentMediaPath } from '../core/services';
|
||||
import { updateCurrentMediaPath } from '../core/services/subtitle-position';
|
||||
|
||||
import type { SubtitlePosition } from '../types';
|
||||
|
||||
|
||||
163
src/main/mining-runtime.ts
Normal file
163
src/main/mining-runtime.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import { appendClipboardVideoToQueueRuntime } from './runtime/clipboard-queue';
|
||||
import {
|
||||
createUpdateLastCardFromClipboardHandler,
|
||||
createRefreshKnownWordCacheHandler,
|
||||
createTriggerFieldGroupingHandler,
|
||||
createMarkLastCardAsAudioCardHandler,
|
||||
createMineSentenceCardHandler,
|
||||
} from './runtime/anki-actions';
|
||||
import {
|
||||
createHandleMultiCopyDigitHandler,
|
||||
createCopyCurrentSubtitleHandler,
|
||||
createHandleMineSentenceDigitHandler,
|
||||
} from './runtime/mining-actions';
|
||||
|
||||
export interface MiningRuntimeInput<TAnkiIntegration = unknown, TMpvClient = unknown> {
|
||||
getSubtitleTimingTracker: () => SubtitleTimingTracker;
|
||||
getAnkiIntegration: () => TAnkiIntegration;
|
||||
getMpvClient: () => TMpvClient;
|
||||
readClipboardText: () => string;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
updateLastCardFromClipboardCore: (options: {
|
||||
ankiIntegration: TAnkiIntegration;
|
||||
readClipboardText: () => string;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
triggerFieldGroupingCore: (options: {
|
||||
ankiIntegration: TAnkiIntegration;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
markLastCardAsAudioCardCore: (options: {
|
||||
ankiIntegration: TAnkiIntegration;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<void>;
|
||||
mineSentenceCardCore: (options: {
|
||||
ankiIntegration: TAnkiIntegration;
|
||||
mpvClient: TMpvClient;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
handleMultiCopyDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
subtitleTimingTracker: SubtitleTimingTracker;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
},
|
||||
) => void;
|
||||
copyCurrentSubtitleCore: (options: {
|
||||
subtitleTimingTracker: SubtitleTimingTracker;
|
||||
writeClipboardText: (text: string) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => void;
|
||||
handleMineSentenceDigitCore: (
|
||||
count: number,
|
||||
options: {
|
||||
subtitleTimingTracker: SubtitleTimingTracker;
|
||||
ankiIntegration: TAnkiIntegration;
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
showMpvOsd: (text: string) => void;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
onCardsMined: (count: number) => void;
|
||||
},
|
||||
) => void;
|
||||
getCurrentSecondarySubText: () => string | undefined;
|
||||
logError: (message: string, err: unknown) => void;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}
|
||||
|
||||
export interface MiningRuntime {
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
handleMultiCopyDigit: (count: number) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
handleMineSentenceDigit: (count: number) => void;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
}
|
||||
|
||||
export function createMiningRuntime<TAnkiIntegration, TMpvClient>(
|
||||
input: MiningRuntimeInput<TAnkiIntegration, TMpvClient>,
|
||||
): MiningRuntime {
|
||||
const updateLastCardFromClipboard = createUpdateLastCardFromClipboardHandler({
|
||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
||||
readClipboardText: () => input.readClipboardText(),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
updateLastCardFromClipboardCore: (options) => input.updateLastCardFromClipboardCore(options),
|
||||
});
|
||||
|
||||
const refreshKnownWordCache = createRefreshKnownWordCacheHandler({
|
||||
getAnkiIntegration: () =>
|
||||
input.getAnkiIntegration() as { refreshKnownWordCache: () => Promise<void> } | null,
|
||||
missingIntegrationMessage: 'AnkiConnect integration not enabled',
|
||||
});
|
||||
|
||||
const triggerFieldGrouping = createTriggerFieldGroupingHandler({
|
||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
triggerFieldGroupingCore: (options) => input.triggerFieldGroupingCore(options),
|
||||
});
|
||||
|
||||
const markLastCardAsAudioCard = createMarkLastCardAsAudioCardHandler({
|
||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
markLastCardAsAudioCardCore: (options) => input.markLastCardAsAudioCardCore(options),
|
||||
});
|
||||
|
||||
const mineSentenceCard = createMineSentenceCardHandler({
|
||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
||||
getMpvClient: () => input.getMpvClient(),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
mineSentenceCardCore: (options) => input.mineSentenceCardCore(options),
|
||||
recordCardsMined: (count, noteIds) => input.recordCardsMined(count, noteIds),
|
||||
});
|
||||
|
||||
const handleMultiCopyDigit = createHandleMultiCopyDigitHandler({
|
||||
getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(),
|
||||
writeClipboardText: (text) => input.writeClipboardText(text),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
handleMultiCopyDigitCore: (count, options) => input.handleMultiCopyDigitCore(count, options),
|
||||
});
|
||||
|
||||
const copyCurrentSubtitle = createCopyCurrentSubtitleHandler({
|
||||
getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(),
|
||||
writeClipboardText: (text) => input.writeClipboardText(text),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
copyCurrentSubtitleCore: (options) => input.copyCurrentSubtitleCore(options),
|
||||
});
|
||||
|
||||
const handleMineSentenceDigit = createHandleMineSentenceDigitHandler({
|
||||
getSubtitleTimingTracker: () => input.getSubtitleTimingTracker(),
|
||||
getAnkiIntegration: () => input.getAnkiIntegration(),
|
||||
getCurrentSecondarySubText: () => input.getCurrentSecondarySubText(),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
logError: (message, err) => input.logError(message, err),
|
||||
onCardsMined: (count) => input.recordCardsMined(count),
|
||||
handleMineSentenceDigitCore: (count, options) =>
|
||||
input.handleMineSentenceDigitCore(count, options),
|
||||
});
|
||||
|
||||
const appendClipboardVideoToQueue = (): { ok: boolean; message: string } =>
|
||||
appendClipboardVideoToQueueRuntime({
|
||||
getMpvClient: () => input.getMpvClient() as { connected: boolean } | null,
|
||||
readClipboardText: () => input.readClipboardText(),
|
||||
showMpvOsd: (text) => input.showMpvOsd(text),
|
||||
sendMpvCommand: (command) => input.sendMpvCommand(command),
|
||||
});
|
||||
|
||||
return {
|
||||
updateLastCardFromClipboard,
|
||||
refreshKnownWordCache,
|
||||
triggerFieldGrouping,
|
||||
markLastCardAsAudioCard,
|
||||
mineSentenceCard,
|
||||
handleMultiCopyDigit,
|
||||
copyCurrentSubtitle,
|
||||
handleMineSentenceDigit,
|
||||
appendClipboardVideoToQueue,
|
||||
};
|
||||
}
|
||||
285
src/main/mpv-runtime-bootstrap.ts
Normal file
285
src/main/mpv-runtime-bootstrap.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import type { MpvRuntime, MpvRuntimeInput } from './mpv-runtime';
|
||||
import { createMpvRuntime } from './mpv-runtime';
|
||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
||||
import type { createCurrentMediaTokenizationGate } from './runtime/current-media-tokenization-gate';
|
||||
import type { createStartupOsdSequencer } from './runtime/startup-osd-sequencer';
|
||||
import type { AnilistRuntime } from './anilist-runtime';
|
||||
import type { JellyfinRuntime } from './jellyfin-runtime';
|
||||
import type { YoutubeRuntime } from './youtube-runtime';
|
||||
|
||||
export interface MpvRuntimeBootstrapInput {
|
||||
appState: MpvRuntimeInput['appState'];
|
||||
logPath: string;
|
||||
logger: MpvRuntimeInput['logger'];
|
||||
getResolvedConfig: MpvRuntimeInput['getResolvedConfig'];
|
||||
getRuntimeBooleanOption: MpvRuntimeInput['getRuntimeBooleanOption'];
|
||||
subtitle: MpvRuntimeInput['subtitle'];
|
||||
ensureYomitanExtensionLoaded: MpvRuntimeInput['ensureYomitanExtensionLoaded'];
|
||||
currentMediaTokenizationGate: MpvRuntimeInput['currentMediaTokenizationGate'];
|
||||
startupOsdSequencer: MpvRuntimeInput['startupOsdSequencer'];
|
||||
dictionarySupport: {
|
||||
ensureJlptDictionaryLookup: () => Promise<void>;
|
||||
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
||||
syncImmersionMediaState: () => void;
|
||||
updateCurrentMediaPath: (mediaPath: unknown) => void;
|
||||
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
|
||||
scheduleCharacterDictionarySync: () => void;
|
||||
};
|
||||
overlay: {
|
||||
overlayManager: {
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
};
|
||||
getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined;
|
||||
};
|
||||
lifecycle: {
|
||||
requestAppQuit: () => void;
|
||||
setQuitCheckTimer: (callback: () => void, timeoutMs: number) => void;
|
||||
restoreOverlayMpvSubtitles: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
publishDiscordPresence: () => void;
|
||||
};
|
||||
stats: MpvRuntimeInput['stats'];
|
||||
anilist: MpvRuntimeInput['anilist'];
|
||||
jellyfin: MpvRuntimeInput['jellyfin'];
|
||||
youtube: MpvRuntimeInput['youtube'];
|
||||
isCharacterDictionaryEnabled: MpvRuntimeInput['isCharacterDictionaryEnabled'];
|
||||
}
|
||||
|
||||
export interface MpvRuntimeBootstrap {
|
||||
mpvRuntime: MpvRuntime;
|
||||
}
|
||||
|
||||
export interface MpvRuntimeFromMainStateInput {
|
||||
appState: MpvRuntimeInput['appState'];
|
||||
logPath: string;
|
||||
logger: MpvRuntimeInput['logger'];
|
||||
getResolvedConfig: MpvRuntimeInput['getResolvedConfig'];
|
||||
getRuntimeBooleanOption: MpvRuntimeInput['getRuntimeBooleanOption'];
|
||||
subtitle: SubtitleRuntime;
|
||||
yomitan: {
|
||||
ensureYomitanExtensionLoaded: () => Promise<void>;
|
||||
};
|
||||
currentMediaTokenizationGate: ReturnType<typeof createCurrentMediaTokenizationGate>;
|
||||
startupOsdSequencer: ReturnType<typeof createStartupOsdSequencer>;
|
||||
dictionarySupport: DictionarySupportRuntime;
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getOverlayUi: () => { setOverlayVisible: (visible: boolean) => void } | undefined;
|
||||
};
|
||||
lifecycle: {
|
||||
requestAppQuit: () => void;
|
||||
setQuitCheckTimer: (callback: () => void, timeoutMs: number) => void;
|
||||
restoreOverlayMpvSubtitles: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
publishDiscordPresence: () => void;
|
||||
};
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
};
|
||||
anilist: AnilistRuntime;
|
||||
jellyfin: JellyfinRuntime;
|
||||
youtube: YoutubeRuntime;
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
}
|
||||
|
||||
export function createMpvRuntimeBootstrap(input: MpvRuntimeBootstrapInput): MpvRuntimeBootstrap {
|
||||
const mpvRuntime = createMpvRuntime({
|
||||
appState: input.appState,
|
||||
logPath: input.logPath,
|
||||
logger: input.logger,
|
||||
getResolvedConfig: input.getResolvedConfig,
|
||||
getRuntimeBooleanOption: input.getRuntimeBooleanOption,
|
||||
subtitle: input.subtitle,
|
||||
ensureYomitanExtensionLoaded: input.ensureYomitanExtensionLoaded,
|
||||
currentMediaTokenizationGate: input.currentMediaTokenizationGate,
|
||||
startupOsdSequencer: input.startupOsdSequencer,
|
||||
dictionaries: {
|
||||
ensureJlptDictionaryLookup: () => input.dictionarySupport.ensureJlptDictionaryLookup(),
|
||||
ensureFrequencyDictionaryLookup: () =>
|
||||
input.dictionarySupport.ensureFrequencyDictionaryLookup(),
|
||||
},
|
||||
mediaRuntime: {
|
||||
syncImmersionMediaState: () => input.dictionarySupport.syncImmersionMediaState(),
|
||||
updateCurrentMediaPath: (mediaPath) => {
|
||||
input.dictionarySupport.updateCurrentMediaPath(mediaPath);
|
||||
},
|
||||
updateCurrentMediaTitle: (mediaTitle) => {
|
||||
input.dictionarySupport.updateCurrentMediaTitle(mediaTitle);
|
||||
},
|
||||
},
|
||||
characterDictionaryAutoSyncRuntime: {
|
||||
scheduleSync: () => {
|
||||
input.dictionarySupport.scheduleCharacterDictionarySync();
|
||||
},
|
||||
},
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
input.overlay.overlayManager.broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
getVisibleOverlayVisible: () => input.overlay.overlayManager.getVisibleOverlayVisible(),
|
||||
setOverlayVisible: (visible) => {
|
||||
input.overlay.getOverlayUi()?.setOverlayVisible(visible);
|
||||
},
|
||||
},
|
||||
lifecycle: {
|
||||
requestAppQuit: () => input.lifecycle.requestAppQuit(),
|
||||
scheduleQuitCheck: (callback) => {
|
||||
input.lifecycle.setQuitCheckTimer(callback, 500);
|
||||
},
|
||||
restoreOverlayMpvSubtitles: () => {
|
||||
input.lifecycle.restoreOverlayMpvSubtitles();
|
||||
},
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
input.lifecycle.syncOverlayMpvSubtitleSuppression();
|
||||
},
|
||||
refreshDiscordPresence: () => {
|
||||
input.lifecycle.publishDiscordPresence();
|
||||
},
|
||||
},
|
||||
stats: input.stats,
|
||||
anilist: input.anilist,
|
||||
jellyfin: input.jellyfin,
|
||||
youtube: input.youtube,
|
||||
isCharacterDictionaryEnabled: input.isCharacterDictionaryEnabled,
|
||||
});
|
||||
|
||||
return {
|
||||
mpvRuntime,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMpvRuntimeFromMainState(
|
||||
input: MpvRuntimeFromMainStateInput,
|
||||
): MpvRuntimeBootstrap {
|
||||
return createMpvRuntimeBootstrap({
|
||||
appState: input.appState,
|
||||
logPath: input.logPath,
|
||||
logger: input.logger,
|
||||
getResolvedConfig: input.getResolvedConfig,
|
||||
getRuntimeBooleanOption: input.getRuntimeBooleanOption,
|
||||
subtitle: {
|
||||
consumeCachedSubtitle: (text) => input.subtitle.consumeCachedSubtitle(text),
|
||||
emitSubtitlePayload: (payload) => input.subtitle.emitSubtitlePayload(payload),
|
||||
onSubtitleChange: (text) => {
|
||||
input.subtitle.onSubtitleChange(text);
|
||||
},
|
||||
onCurrentMediaPathChange: (path) => {
|
||||
input.subtitle.onCurrentMediaPathChange(path);
|
||||
},
|
||||
onTimePosUpdate: (time) => {
|
||||
input.subtitle.onTimePosUpdate(time);
|
||||
},
|
||||
scheduleSubtitlePrefetchRefresh: (delayMs) =>
|
||||
input.subtitle.scheduleSubtitlePrefetchRefresh(delayMs),
|
||||
loadSubtitleSourceText: (source) => input.subtitle.loadSubtitleSourceText(source),
|
||||
setTokenizeSubtitleDeferred: (tokenize) => {
|
||||
input.subtitle.setTokenizeSubtitleDeferred(tokenize);
|
||||
},
|
||||
},
|
||||
ensureYomitanExtensionLoaded: async () => {
|
||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
||||
},
|
||||
currentMediaTokenizationGate: input.currentMediaTokenizationGate,
|
||||
startupOsdSequencer: input.startupOsdSequencer,
|
||||
dictionarySupport: {
|
||||
ensureJlptDictionaryLookup: () => input.dictionarySupport.ensureJlptDictionaryLookup(),
|
||||
ensureFrequencyDictionaryLookup: () =>
|
||||
input.dictionarySupport.ensureFrequencyDictionaryLookup(),
|
||||
syncImmersionMediaState: () => {
|
||||
input.dictionarySupport.syncImmersionMediaState();
|
||||
},
|
||||
updateCurrentMediaPath: (mediaPath) => {
|
||||
input.dictionarySupport.updateCurrentMediaPath(mediaPath);
|
||||
},
|
||||
updateCurrentMediaTitle: (mediaTitle) => {
|
||||
input.dictionarySupport.updateCurrentMediaTitle(mediaTitle);
|
||||
},
|
||||
scheduleCharacterDictionarySync: () => {
|
||||
input.dictionarySupport.scheduleCharacterDictionarySync();
|
||||
},
|
||||
},
|
||||
overlay: {
|
||||
overlayManager: {
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
input.overlay.broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
getVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
|
||||
},
|
||||
getOverlayUi: () => input.overlay.getOverlayUi(),
|
||||
},
|
||||
lifecycle: {
|
||||
requestAppQuit: () => input.lifecycle.requestAppQuit(),
|
||||
setQuitCheckTimer: (callback, timeoutMs) => {
|
||||
input.lifecycle.setQuitCheckTimer(callback, timeoutMs);
|
||||
},
|
||||
restoreOverlayMpvSubtitles: () => {
|
||||
input.lifecycle.restoreOverlayMpvSubtitles();
|
||||
},
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
input.lifecycle.syncOverlayMpvSubtitleSuppression();
|
||||
},
|
||||
publishDiscordPresence: () => {
|
||||
input.lifecycle.publishDiscordPresence();
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => input.stats.ensureImmersionTrackerStarted(),
|
||||
},
|
||||
anilist: {
|
||||
getCurrentAnilistMediaKey: () => input.anilist.getCurrentAnilistMediaKey(),
|
||||
resetAnilistMediaTracking: (mediaKey) => {
|
||||
input.anilist.resetAnilistMediaTracking(mediaKey);
|
||||
},
|
||||
maybeProbeAnilistDuration: (mediaKey) => {
|
||||
if (mediaKey) {
|
||||
void input.anilist.maybeProbeAnilistDuration(mediaKey);
|
||||
}
|
||||
},
|
||||
ensureAnilistMediaGuess: (mediaKey) => {
|
||||
if (mediaKey) {
|
||||
void input.anilist.ensureAnilistMediaGuess(mediaKey);
|
||||
}
|
||||
},
|
||||
maybeRunAnilistPostWatchUpdate: () => input.anilist.maybeRunAnilistPostWatchUpdate(),
|
||||
resetAnilistMediaGuessState: () => {
|
||||
input.anilist.resetAnilistMediaGuessState();
|
||||
},
|
||||
},
|
||||
jellyfin: {
|
||||
getQuitOnDisconnectArmed: () => input.jellyfin.getQuitOnDisconnectArmed(),
|
||||
reportJellyfinRemoteStopped: () => input.jellyfin.reportJellyfinRemoteStopped(),
|
||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||
input.jellyfin.reportJellyfinRemoteProgress(forceImmediate),
|
||||
startJellyfinRemoteSession: () => input.jellyfin.startJellyfinRemoteSession(),
|
||||
},
|
||||
youtube: {
|
||||
getQuitOnDisconnectArmed: () => input.youtube.getQuitOnDisconnectArmed(),
|
||||
handleMpvConnectionChange: (connected) => {
|
||||
input.youtube.handleMpvConnectionChange(connected);
|
||||
},
|
||||
handleMediaPathChange: (path) => {
|
||||
input.youtube.invalidatePendingAutoplayReadyFallbacks();
|
||||
input.currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||
input.startupOsdSequencer.reset();
|
||||
input.youtube.handleMediaPathChange(path);
|
||||
if (path) {
|
||||
input.stats.ensureImmersionTrackerStarted();
|
||||
}
|
||||
},
|
||||
handleSubtitleTrackChange: (sid) => {
|
||||
input.youtube.handleSubtitleTrackChange(sid);
|
||||
},
|
||||
handleSubtitleTrackListChange: (trackList) => {
|
||||
input.youtube.handleSubtitleTrackListChange(trackList);
|
||||
},
|
||||
invalidatePendingAutoplayReadyFallbacks: () =>
|
||||
input.youtube.invalidatePendingAutoplayReadyFallbacks(),
|
||||
maybeSignalPluginAutoplayReady: (subtitle, options) =>
|
||||
input.youtube.maybeSignalPluginAutoplayReady(subtitle, options),
|
||||
},
|
||||
isCharacterDictionaryEnabled: input.isCharacterDictionaryEnabled,
|
||||
});
|
||||
}
|
||||
504
src/main/mpv-runtime.ts
Normal file
504
src/main/mpv-runtime.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { MecabTokenizer } from '../mecab-tokenizer';
|
||||
import {
|
||||
MpvIpcClient,
|
||||
applyMpvSubtitleRenderMetricsPatch,
|
||||
createShiftSubtitleDelayToAdjacentCueHandler,
|
||||
createTokenizerDepsRuntime,
|
||||
cycleSecondarySubMode as cycleSecondarySubModeCore,
|
||||
sendMpvCommandRuntime,
|
||||
showMpvOsdRuntime,
|
||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||
} from '../core/services';
|
||||
import type {
|
||||
MpvSubtitleRenderMetrics,
|
||||
ResolvedConfig,
|
||||
SecondarySubMode,
|
||||
SubtitleData,
|
||||
} from '../types';
|
||||
import type { AppState } from './state';
|
||||
import type { SubtitleRuntime } from './subtitle-runtime';
|
||||
import type { createCurrentMediaTokenizationGate } from './runtime/current-media-tokenization-gate';
|
||||
import type { createStartupOsdSequencer } from './runtime/startup-osd-sequencer';
|
||||
import { createMpvOsdRuntimeHandlers } from './runtime/mpv-osd-runtime-handlers';
|
||||
import { createCycleSecondarySubModeRuntimeHandler } from './runtime/secondary-sub-mode-runtime-handler';
|
||||
import { composeMpvRuntimeHandlers } from './runtime/composers';
|
||||
|
||||
type RuntimeOptionId =
|
||||
| 'subtitle.annotation.nPlusOne'
|
||||
| 'subtitle.annotation.jlpt'
|
||||
| 'subtitle.annotation.frequency';
|
||||
|
||||
interface MpvRuntimeLogger {
|
||||
debug: (message: string, meta?: unknown) => void;
|
||||
info: (message: string, meta?: unknown) => void;
|
||||
warn: (message: string, meta?: unknown) => void;
|
||||
error: (message: string, error?: unknown) => void;
|
||||
}
|
||||
|
||||
export interface MpvRuntimeInput {
|
||||
appState: AppState;
|
||||
logPath: string;
|
||||
logger: MpvRuntimeLogger;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getRuntimeBooleanOption: (id: RuntimeOptionId, fallback: boolean) => boolean;
|
||||
subtitle: Pick<
|
||||
SubtitleRuntime,
|
||||
| 'consumeCachedSubtitle'
|
||||
| 'emitSubtitlePayload'
|
||||
| 'onSubtitleChange'
|
||||
| 'onCurrentMediaPathChange'
|
||||
| 'onTimePosUpdate'
|
||||
| 'scheduleSubtitlePrefetchRefresh'
|
||||
| 'loadSubtitleSourceText'
|
||||
| 'setTokenizeSubtitleDeferred'
|
||||
>;
|
||||
ensureYomitanExtensionLoaded: () => Promise<void>;
|
||||
currentMediaTokenizationGate: ReturnType<typeof createCurrentMediaTokenizationGate>;
|
||||
startupOsdSequencer: ReturnType<typeof createStartupOsdSequencer>;
|
||||
dictionaries: {
|
||||
ensureJlptDictionaryLookup: () => Promise<void>;
|
||||
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
||||
};
|
||||
mediaRuntime: {
|
||||
syncImmersionMediaState: () => void;
|
||||
updateCurrentMediaPath: (mediaPath: unknown) => void;
|
||||
updateCurrentMediaTitle: (mediaTitle: unknown) => void;
|
||||
};
|
||||
characterDictionaryAutoSyncRuntime: {
|
||||
scheduleSync: () => void;
|
||||
};
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setOverlayVisible: (visible: boolean) => void;
|
||||
};
|
||||
lifecycle: {
|
||||
requestAppQuit: () => void;
|
||||
scheduleQuitCheck: (callback: () => void) => void;
|
||||
restoreOverlayMpvSubtitles: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
};
|
||||
stats: {
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
};
|
||||
anilist: {
|
||||
getCurrentAnilistMediaKey: () => string | null;
|
||||
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
||||
maybeProbeAnilistDuration: (mediaKey: string | null) => void;
|
||||
ensureAnilistMediaGuess: (mediaKey: string | null) => void;
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
};
|
||||
jellyfin: {
|
||||
getQuitOnDisconnectArmed: () => boolean;
|
||||
reportJellyfinRemoteStopped: () => Promise<void>;
|
||||
reportJellyfinRemoteProgress: (forceImmediate?: boolean) => Promise<void>;
|
||||
startJellyfinRemoteSession: () => Promise<void>;
|
||||
};
|
||||
youtube: {
|
||||
getQuitOnDisconnectArmed: () => boolean;
|
||||
handleMpvConnectionChange: (connected: boolean) => void;
|
||||
handleMediaPathChange: (path: string | null) => void;
|
||||
handleSubtitleTrackChange: (sid: number | null) => void;
|
||||
handleSubtitleTrackListChange: (trackList: unknown[] | null) => void;
|
||||
invalidatePendingAutoplayReadyFallbacks: () => void;
|
||||
maybeSignalPluginAutoplayReady: (
|
||||
subtitle: { text: string; tokens: null },
|
||||
options?: { forceWhilePaused?: boolean },
|
||||
) => void;
|
||||
};
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
}
|
||||
|
||||
export interface MpvRuntime {
|
||||
createMpvClientRuntimeService: () => MpvIpcClient;
|
||||
updateMpvSubtitleRenderMetrics: (patch: Partial<MpvSubtitleRenderMetrics>) => void;
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
prewarmSubtitleDictionaries: () => Promise<void>;
|
||||
startTokenizationWarmups: () => Promise<void>;
|
||||
isTokenizationWarmupReady: () => boolean;
|
||||
startBackgroundWarmups: () => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
flushMpvLog: () => Promise<void>;
|
||||
cycleSecondarySubMode: () => void;
|
||||
shiftSubtitleDelayToAdjacentCue: (direction: 'next' | 'previous') => Promise<void>;
|
||||
}
|
||||
|
||||
function getActiveMediaPath(appState: AppState): string | null {
|
||||
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
|
||||
}
|
||||
|
||||
export function createMpvRuntime(input: MpvRuntimeInput): MpvRuntime {
|
||||
let backgroundWarmupsStarted = false;
|
||||
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
|
||||
|
||||
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
|
||||
appendToMpvLogMainDeps: {
|
||||
logPath: input.logPath,
|
||||
dirname: (targetPath) => path.dirname(targetPath),
|
||||
mkdir: async (targetPath, options) => {
|
||||
await fs.promises.mkdir(targetPath, options);
|
||||
},
|
||||
appendFile: async (targetPath, data, options) => {
|
||||
await fs.promises.appendFile(targetPath, data, options);
|
||||
},
|
||||
now: () => new Date(),
|
||||
},
|
||||
buildShowMpvOsdMainDeps: (appendToMpvLog) => ({
|
||||
appendToMpvLog,
|
||||
showMpvOsdRuntime: (mpvClient, text, fallbackLog) =>
|
||||
showMpvOsdRuntime(mpvClient, text, fallbackLog),
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
logInfo: (line) => input.logger.info(line),
|
||||
}),
|
||||
});
|
||||
|
||||
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
|
||||
cycleSecondarySubModeMainDeps: {
|
||||
getSecondarySubMode: () => input.appState.secondarySubMode,
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => {
|
||||
input.appState.secondarySubMode = mode;
|
||||
},
|
||||
getLastSecondarySubToggleAtMs: () => input.appState.lastSecondarySubToggleAtMs,
|
||||
setLastSecondarySubToggleAtMs: (timestampMs: number) => {
|
||||
input.appState.lastSecondarySubToggleAtMs = timestampMs;
|
||||
},
|
||||
broadcastToOverlayWindows: (channel, mode) => {
|
||||
input.overlay.broadcastToOverlayWindows(channel, mode);
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
},
|
||||
cycleSecondarySubMode: (deps) => cycleSecondarySubModeCore(deps),
|
||||
});
|
||||
|
||||
const {
|
||||
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
|
||||
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
|
||||
tokenizeSubtitle,
|
||||
createMecabTokenizerAndCheck,
|
||||
prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups,
|
||||
startTokenizationWarmups,
|
||||
isTokenizationWarmupReady,
|
||||
} = composeMpvRuntimeHandlers<
|
||||
MpvIpcClient,
|
||||
ReturnType<typeof createTokenizerDepsRuntime>,
|
||||
SubtitleData
|
||||
>({
|
||||
bindMpvMainEventHandlersMainDeps: {
|
||||
appState: input.appState,
|
||||
getQuitOnDisconnectArmed: () =>
|
||||
input.jellyfin.getQuitOnDisconnectArmed() || input.youtube.getQuitOnDisconnectArmed(),
|
||||
scheduleQuitCheck: (callback) => {
|
||||
input.lifecycle.scheduleQuitCheck(callback);
|
||||
},
|
||||
quitApp: () => input.lifecycle.requestAppQuit(),
|
||||
reportJellyfinRemoteStopped: () => {
|
||||
void input.jellyfin.reportJellyfinRemoteStopped();
|
||||
},
|
||||
maybeRunAnilistPostWatchUpdate: () => input.anilist.maybeRunAnilistPostWatchUpdate(),
|
||||
logSubtitleTimingError: (message, error) => input.logger.error(message, error),
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
input.overlay.broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
getImmediateSubtitlePayload: (text) => input.subtitle.consumeCachedSubtitle(text),
|
||||
emitImmediateSubtitle: (payload) => {
|
||||
input.subtitle.emitSubtitlePayload(payload);
|
||||
},
|
||||
onSubtitleChange: (text) => {
|
||||
input.subtitle.onSubtitleChange(text);
|
||||
},
|
||||
refreshDiscordPresence: () => {
|
||||
input.lifecycle.refreshDiscordPresence();
|
||||
},
|
||||
ensureImmersionTrackerInitialized: () => {
|
||||
input.stats.ensureImmersionTrackerStarted();
|
||||
},
|
||||
tokenizeSubtitleForImmersion: async (text): Promise<SubtitleData | null> =>
|
||||
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
|
||||
updateCurrentMediaPath: (mediaPath) => {
|
||||
input.youtube.invalidatePendingAutoplayReadyFallbacks();
|
||||
input.currentMediaTokenizationGate.updateCurrentMediaPath(mediaPath);
|
||||
input.startupOsdSequencer.reset();
|
||||
input.subtitle.onCurrentMediaPathChange(mediaPath);
|
||||
input.youtube.handleMediaPathChange(mediaPath);
|
||||
if (mediaPath) {
|
||||
input.stats.ensureImmersionTrackerStarted();
|
||||
}
|
||||
input.mediaRuntime.updateCurrentMediaPath(mediaPath);
|
||||
},
|
||||
restoreMpvSubVisibility: () => {
|
||||
input.lifecycle.restoreOverlayMpvSubtitles();
|
||||
},
|
||||
resetSubtitleSidebarEmbeddedLayout: () => {
|
||||
sendMpvCommandRuntime(input.appState.mpvClient, [
|
||||
'set_property',
|
||||
'video-margin-ratio-right',
|
||||
0,
|
||||
]);
|
||||
sendMpvCommandRuntime(input.appState.mpvClient, ['set_property', 'video-pan-x', 0]);
|
||||
},
|
||||
getCurrentAnilistMediaKey: () => input.anilist.getCurrentAnilistMediaKey(),
|
||||
resetAnilistMediaTracking: (mediaKey) => {
|
||||
input.anilist.resetAnilistMediaTracking(mediaKey);
|
||||
},
|
||||
maybeProbeAnilistDuration: (mediaKey) => {
|
||||
if (mediaKey) {
|
||||
input.anilist.maybeProbeAnilistDuration(mediaKey);
|
||||
}
|
||||
},
|
||||
ensureAnilistMediaGuess: (mediaKey) => {
|
||||
if (mediaKey) {
|
||||
input.anilist.ensureAnilistMediaGuess(mediaKey);
|
||||
}
|
||||
},
|
||||
syncImmersionMediaState: () => {
|
||||
input.mediaRuntime.syncImmersionMediaState();
|
||||
},
|
||||
signalAutoplayReadyIfWarm: () => {
|
||||
if (!isTokenizationWarmupReady()) {
|
||||
return;
|
||||
}
|
||||
input.youtube.maybeSignalPluginAutoplayReady(
|
||||
{ text: '__warm__', tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
},
|
||||
scheduleCharacterDictionarySync: () => {
|
||||
if (!input.isCharacterDictionaryEnabled()) {
|
||||
return;
|
||||
}
|
||||
input.characterDictionaryAutoSyncRuntime.scheduleSync();
|
||||
},
|
||||
updateCurrentMediaTitle: (title) => {
|
||||
input.mediaRuntime.updateCurrentMediaTitle(title);
|
||||
},
|
||||
resetAnilistMediaGuessState: () => {
|
||||
input.anilist.resetAnilistMediaGuessState();
|
||||
},
|
||||
reportJellyfinRemoteProgress: (forceImmediate) => {
|
||||
void input.jellyfin.reportJellyfinRemoteProgress(forceImmediate);
|
||||
},
|
||||
onTimePosUpdate: (time) => {
|
||||
input.subtitle.onTimePosUpdate(time);
|
||||
},
|
||||
onSubtitleTrackChange: (sid) => {
|
||||
input.subtitle.scheduleSubtitlePrefetchRefresh();
|
||||
input.youtube.handleSubtitleTrackChange(sid);
|
||||
},
|
||||
onSubtitleTrackListChange: (trackList) => {
|
||||
input.subtitle.scheduleSubtitlePrefetchRefresh();
|
||||
input.youtube.handleSubtitleTrackListChange(trackList);
|
||||
},
|
||||
updateSubtitleRenderMetrics: (patch) => {
|
||||
updateMpvSubtitleRenderMetricsHandler(patch as Partial<MpvSubtitleRenderMetrics>);
|
||||
},
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
input.lifecycle.syncOverlayMpvSubtitleSuppression();
|
||||
},
|
||||
},
|
||||
mpvClientRuntimeServiceFactoryMainDeps: {
|
||||
createClient: MpvIpcClient,
|
||||
getSocketPath: () => input.appState.mpvSocketPath,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
isAutoStartOverlayEnabled: () => input.appState.autoStartOverlay,
|
||||
setOverlayVisible: (visible: boolean) => {
|
||||
input.overlay.setOverlayVisible(visible);
|
||||
},
|
||||
isVisibleOverlayVisible: () => input.overlay.getVisibleOverlayVisible(),
|
||||
getReconnectTimer: () => input.appState.reconnectTimer,
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
|
||||
input.appState.reconnectTimer = timer;
|
||||
},
|
||||
},
|
||||
updateMpvSubtitleRenderMetricsMainDeps: {
|
||||
getCurrentMetrics: () => input.appState.mpvSubtitleRenderMetrics,
|
||||
setCurrentMetrics: (metrics) => {
|
||||
input.appState.mpvSubtitleRenderMetrics = metrics;
|
||||
},
|
||||
applyPatch: (current, patch) => applyMpvSubtitleRenderMetricsPatch(current, patch),
|
||||
broadcastMetrics: () => {},
|
||||
},
|
||||
tokenizer: {
|
||||
buildTokenizerDepsMainDeps: {
|
||||
getYomitanExt: () => input.appState.yomitanExt,
|
||||
getYomitanSession: () => input.appState.yomitanSession,
|
||||
getYomitanParserWindow: () => input.appState.yomitanParserWindow,
|
||||
setYomitanParserWindow: (window) => {
|
||||
input.appState.yomitanParserWindow = window;
|
||||
},
|
||||
getYomitanParserReadyPromise: () => input.appState.yomitanParserReadyPromise,
|
||||
setYomitanParserReadyPromise: (promise) => {
|
||||
input.appState.yomitanParserReadyPromise = promise;
|
||||
},
|
||||
getYomitanParserInitPromise: () => input.appState.yomitanParserInitPromise,
|
||||
setYomitanParserInitPromise: (promise) => {
|
||||
input.appState.yomitanParserInitPromise = promise;
|
||||
},
|
||||
isKnownWord: (text) => Boolean(input.appState.ankiIntegration?.isKnownWord(text)),
|
||||
recordLookup: (hit) => {
|
||||
input.stats.ensureImmersionTrackerStarted();
|
||||
input.appState.immersionTracker?.recordLookup(hit);
|
||||
},
|
||||
getKnownWordMatchMode: () =>
|
||||
input.appState.ankiIntegration?.getKnownWordMatchMode() ??
|
||||
input.getResolvedConfig().ankiConnect.knownWords.matchMode,
|
||||
getNPlusOneEnabled: () =>
|
||||
input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
input.getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
||||
),
|
||||
getMinSentenceWordsForNPlusOne: () =>
|
||||
input.getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
|
||||
getJlptLevel: (text) => input.appState.jlptLevelLookup(text),
|
||||
getJlptEnabled: () =>
|
||||
input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.jlpt',
|
||||
input.getResolvedConfig().subtitleStyle.enableJlpt,
|
||||
),
|
||||
getCharacterDictionaryEnabled: () => input.isCharacterDictionaryEnabled(),
|
||||
getNameMatchEnabled: () => input.getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||
getFrequencyDictionaryEnabled: () =>
|
||||
input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.frequency',
|
||||
input.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
),
|
||||
getFrequencyDictionaryMatchMode: () =>
|
||||
input.getResolvedConfig().subtitleStyle.frequencyDictionary.matchMode,
|
||||
getFrequencyRank: (text) => input.appState.frequencyRankLookup(text),
|
||||
getYomitanGroupDebugEnabled: () => input.appState.overlayDebugVisualizationEnabled,
|
||||
getMecabTokenizer: () => input.appState.mecabTokenizer,
|
||||
onTokenizationReady: (text) => {
|
||||
input.currentMediaTokenizationGate.markReady(getActiveMediaPath(input.appState));
|
||||
input.startupOsdSequencer.markTokenizationReady();
|
||||
input.youtube.maybeSignalPluginAutoplayReady(
|
||||
{ text, tokens: null },
|
||||
{ forceWhilePaused: true },
|
||||
);
|
||||
},
|
||||
},
|
||||
createTokenizerRuntimeDeps: (deps) =>
|
||||
createTokenizerDepsRuntime(deps as Parameters<typeof createTokenizerDepsRuntime>[0]),
|
||||
tokenizeSubtitle: (text, deps) => tokenizeSubtitleCore(text, deps),
|
||||
createMecabTokenizerAndCheckMainDeps: {
|
||||
getMecabTokenizer: () => input.appState.mecabTokenizer,
|
||||
setMecabTokenizer: (tokenizer) => {
|
||||
input.appState.mecabTokenizer = tokenizer as MecabTokenizer | null;
|
||||
},
|
||||
createMecabTokenizer: () => new MecabTokenizer(),
|
||||
checkAvailability: async (tokenizer) => (tokenizer as MecabTokenizer).checkAvailability(),
|
||||
},
|
||||
prewarmSubtitleDictionariesMainDeps: {
|
||||
ensureJlptDictionaryLookup: () => input.dictionaries.ensureJlptDictionaryLookup(),
|
||||
ensureFrequencyDictionaryLookup: () => input.dictionaries.ensureFrequencyDictionaryLookup(),
|
||||
showMpvOsd: (message: string) => showMpvOsd(message),
|
||||
showLoadingOsd: (message: string) =>
|
||||
input.startupOsdSequencer.showAnnotationLoading(message),
|
||||
showLoadedOsd: (message: string) =>
|
||||
input.startupOsdSequencer.markAnnotationLoadingComplete(message),
|
||||
shouldShowOsdNotification: () => {
|
||||
const type = input.getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||
return type === 'osd' || type === 'both';
|
||||
},
|
||||
},
|
||||
},
|
||||
warmups: {
|
||||
launchBackgroundWarmupTaskMainDeps: {
|
||||
now: () => Date.now(),
|
||||
logDebug: (message) => input.logger.debug(message),
|
||||
logWarn: (message) => input.logger.warn(message),
|
||||
},
|
||||
startBackgroundWarmupsMainDeps: {
|
||||
getStarted: () => backgroundWarmupsStarted,
|
||||
setStarted: (started) => {
|
||||
backgroundWarmupsStarted = started;
|
||||
},
|
||||
isTexthookerOnlyMode: () => input.appState.texthookerOnlyMode,
|
||||
ensureYomitanExtensionLoaded: () => input.ensureYomitanExtensionLoaded().then(() => {}),
|
||||
shouldWarmupMecab: () => {
|
||||
const startupWarmups = input.getResolvedConfig().startupWarmups;
|
||||
if (startupWarmups.lowPowerMode) {
|
||||
return false;
|
||||
}
|
||||
if (!startupWarmups.mecab) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
input.getResolvedConfig().ankiConnect.knownWords.highlightEnabled,
|
||||
) ||
|
||||
input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.jlpt',
|
||||
input.getResolvedConfig().subtitleStyle.enableJlpt,
|
||||
) ||
|
||||
input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.frequency',
|
||||
input.getResolvedConfig().subtitleStyle.frequencyDictionary.enabled,
|
||||
)
|
||||
);
|
||||
},
|
||||
shouldWarmupYomitanExtension: () =>
|
||||
input.getResolvedConfig().startupWarmups.yomitanExtension,
|
||||
shouldWarmupSubtitleDictionaries: () => {
|
||||
const startupWarmups = input.getResolvedConfig().startupWarmups;
|
||||
if (startupWarmups.lowPowerMode) {
|
||||
return false;
|
||||
}
|
||||
return startupWarmups.subtitleDictionaries;
|
||||
},
|
||||
shouldWarmupJellyfinRemoteSession: () => {
|
||||
const startupWarmups = input.getResolvedConfig().startupWarmups;
|
||||
if (startupWarmups.lowPowerMode) {
|
||||
return false;
|
||||
}
|
||||
return startupWarmups.jellyfinRemoteSession;
|
||||
},
|
||||
shouldAutoConnectJellyfinRemote: () => {
|
||||
const jellyfin = input.getResolvedConfig().jellyfin;
|
||||
return (
|
||||
jellyfin.enabled && jellyfin.remoteControlEnabled && jellyfin.remoteControlAutoConnect
|
||||
);
|
||||
},
|
||||
startJellyfinRemoteSession: () => input.jellyfin.startJellyfinRemoteSession(),
|
||||
logDebug: (message) => input.logger.debug(message),
|
||||
},
|
||||
},
|
||||
});
|
||||
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
||||
input.subtitle.setTokenizeSubtitleDeferred(tokenizeSubtitle);
|
||||
|
||||
const createMpvClientRuntimeService = (): MpvIpcClient => {
|
||||
const client = createMpvClientRuntimeServiceHandler();
|
||||
client.on('connection-change', ({ connected }) => {
|
||||
input.youtube.handleMpvConnectionChange(connected);
|
||||
});
|
||||
return client;
|
||||
};
|
||||
|
||||
const shiftSubtitleDelayToAdjacentCue = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
loadSubtitleSourceText: (source) => input.subtitle.loadSubtitleSourceText(source),
|
||||
sendMpvCommand: (command) => sendMpvCommandRuntime(input.appState.mpvClient, command),
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
});
|
||||
|
||||
return {
|
||||
createMpvClientRuntimeService,
|
||||
updateMpvSubtitleRenderMetrics: (patch) => {
|
||||
updateMpvSubtitleRenderMetricsHandler(patch);
|
||||
},
|
||||
createMecabTokenizerAndCheck,
|
||||
prewarmSubtitleDictionaries,
|
||||
startTokenizationWarmups,
|
||||
isTokenizationWarmupReady,
|
||||
startBackgroundWarmups,
|
||||
showMpvOsd,
|
||||
flushMpvLog,
|
||||
cycleSecondarySubMode,
|
||||
shiftSubtitleDelayToAdjacentCue,
|
||||
};
|
||||
}
|
||||
63
src/main/overlay-geometry-accessors.ts
Normal file
63
src/main/overlay-geometry-accessors.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { WindowGeometry } from '../types';
|
||||
import type { OverlayGeometryRuntime } from './overlay-geometry-runtime';
|
||||
|
||||
export function createOverlayGeometryAccessors(deps: {
|
||||
getOverlayGeometryRuntime: () => OverlayGeometryRuntime<any> | null;
|
||||
getWindowTracker: () => { getGeometry?: () => WindowGeometry | null } | null;
|
||||
screen: {
|
||||
getCursorScreenPoint: () => { x: number; y: number };
|
||||
getDisplayNearestPoint: (point: { x: number; y: number }) => {
|
||||
workArea: { x: number; y: number; width: number; height: number };
|
||||
};
|
||||
};
|
||||
}) {
|
||||
const getOverlayGeometryFallback = (): WindowGeometry => {
|
||||
const runtime = deps.getOverlayGeometryRuntime();
|
||||
if (runtime) {
|
||||
return runtime.getOverlayGeometryFallback();
|
||||
}
|
||||
|
||||
const cursorPoint = deps.screen.getCursorScreenPoint();
|
||||
const display = deps.screen.getDisplayNearestPoint(cursorPoint);
|
||||
const bounds = display.workArea;
|
||||
return {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
};
|
||||
};
|
||||
|
||||
const getCurrentOverlayGeometry = (): WindowGeometry => {
|
||||
const runtime = deps.getOverlayGeometryRuntime();
|
||||
if (runtime) {
|
||||
return runtime.getCurrentOverlayGeometry();
|
||||
}
|
||||
|
||||
const trackerGeometry = deps.getWindowTracker()?.getGeometry?.() ?? null;
|
||||
if (trackerGeometry) {
|
||||
return trackerGeometry;
|
||||
}
|
||||
|
||||
return getOverlayGeometryFallback();
|
||||
};
|
||||
|
||||
const geometryMatches = (a: WindowGeometry | null, b: WindowGeometry | null): boolean => {
|
||||
const runtime = deps.getOverlayGeometryRuntime();
|
||||
if (runtime) {
|
||||
return runtime.geometryMatches(a, b);
|
||||
}
|
||||
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
};
|
||||
|
||||
return {
|
||||
getOverlayGeometryFallback,
|
||||
getCurrentOverlayGeometry,
|
||||
geometryMatches,
|
||||
};
|
||||
}
|
||||
75
src/main/overlay-geometry-runtime.test.ts
Normal file
75
src/main/overlay-geometry-runtime.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createOverlayGeometryRuntime } from './overlay-geometry-runtime';
|
||||
|
||||
test('overlay geometry runtime prefers tracker geometry before fallback', () => {
|
||||
const overlayBounds: unknown[] = [];
|
||||
const modalBounds: unknown[] = [];
|
||||
const layerCalls: Array<[unknown, unknown]> = [];
|
||||
const levelCalls: unknown[] = [];
|
||||
|
||||
const runtime = createOverlayGeometryRuntime({
|
||||
screen: {
|
||||
getCursorScreenPoint: () => ({ x: 1, y: 2 }),
|
||||
getDisplayNearestPoint: () => ({
|
||||
workArea: { x: 10, y: 20, width: 30, height: 40 },
|
||||
}),
|
||||
},
|
||||
windowState: {
|
||||
getMainWindow: () =>
|
||||
({
|
||||
isDestroyed: () => false,
|
||||
}) as never,
|
||||
setOverlayWindowBounds: (geometry) => overlayBounds.push(geometry),
|
||||
setModalWindowBounds: (geometry) => modalBounds.push(geometry),
|
||||
getVisibleOverlayVisible: () => true,
|
||||
},
|
||||
getWindowTracker: () => ({
|
||||
getGeometry: () => ({ x: 100, y: 200, width: 300, height: 400 }),
|
||||
}),
|
||||
ensureOverlayWindowLevelCore: (window) => {
|
||||
levelCalls.push(window);
|
||||
},
|
||||
syncOverlayWindowLayer: (window, layer) => {
|
||||
layerCalls.push([window, layer]);
|
||||
},
|
||||
enforceOverlayLayerOrderCore: ({
|
||||
visibleOverlayVisible,
|
||||
mainWindow,
|
||||
ensureOverlayWindowLevel,
|
||||
}) => {
|
||||
if (visibleOverlayVisible && mainWindow) {
|
||||
ensureOverlayWindowLevel(mainWindow);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(runtime.getCurrentOverlayGeometry(), {
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
});
|
||||
assert.equal(
|
||||
runtime.geometryMatches(
|
||||
{ x: 1, y: 2, width: 3, height: 4 },
|
||||
{ x: 1, y: 2, width: 3, height: 4 },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(runtime.geometryMatches({ x: 1, y: 2, width: 3, height: 4 }, null), false);
|
||||
|
||||
runtime.applyOverlayRegions({ x: 7, y: 8, width: 9, height: 10 });
|
||||
assert.deepEqual(overlayBounds, [{ x: 7, y: 8, width: 9, height: 10 }]);
|
||||
assert.deepEqual(modalBounds, [{ x: 7, y: 8, width: 9, height: 10 }]);
|
||||
|
||||
runtime.syncPrimaryOverlayWindowLayer('visible');
|
||||
runtime.ensureOverlayWindowLevel({
|
||||
isDestroyed: () => false,
|
||||
} as never);
|
||||
runtime.enforceOverlayLayerOrder();
|
||||
|
||||
assert.equal(layerCalls.length >= 1, true);
|
||||
assert.equal(levelCalls.length >= 2, true);
|
||||
});
|
||||
135
src/main/overlay-geometry-runtime.ts
Normal file
135
src/main/overlay-geometry-runtime.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
createEnforceOverlayLayerOrderHandler,
|
||||
createEnsureOverlayWindowLevelHandler,
|
||||
createUpdateVisibleOverlayBoundsHandler,
|
||||
} from './runtime/overlay-window-layout';
|
||||
import {
|
||||
createBuildEnforceOverlayLayerOrderMainDepsHandler,
|
||||
createBuildEnsureOverlayWindowLevelMainDepsHandler,
|
||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
|
||||
} from './runtime/overlay-window-layout-main-deps';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
type BrowserWindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
type ScreenLike = {
|
||||
getCursorScreenPoint: () => { x: number; y: number };
|
||||
getDisplayNearestPoint: (point: { x: number; y: number }) => {
|
||||
workArea: { x: number; y: number; width: number; height: number };
|
||||
};
|
||||
};
|
||||
|
||||
export interface OverlayGeometryWindowState<TWindow extends BrowserWindowLike = BrowserWindowLike> {
|
||||
getMainWindow: () => TWindow | null;
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
}
|
||||
|
||||
export interface OverlayGeometryInput<TWindow extends BrowserWindowLike = BrowserWindowLike> {
|
||||
screen: ScreenLike;
|
||||
windowState: OverlayGeometryWindowState<TWindow>;
|
||||
getWindowTracker: () => { getGeometry?: () => WindowGeometry | null } | null;
|
||||
ensureOverlayWindowLevelCore: (window: TWindow) => void;
|
||||
syncOverlayWindowLayer: (window: TWindow, layer: 'visible') => void;
|
||||
enforceOverlayLayerOrderCore: (params: {
|
||||
visibleOverlayVisible: boolean;
|
||||
mainWindow: TWindow | null;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface OverlayGeometryRuntime<TWindow extends BrowserWindowLike = BrowserWindowLike> {
|
||||
getLastOverlayWindowGeometry: () => WindowGeometry | null;
|
||||
getOverlayGeometryFallback: () => WindowGeometry;
|
||||
getCurrentOverlayGeometry: () => WindowGeometry;
|
||||
geometryMatches: (a: WindowGeometry | null, b: WindowGeometry | null) => boolean;
|
||||
applyOverlayRegions: (geometry: WindowGeometry) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
}
|
||||
|
||||
export function createOverlayGeometryRuntime<TWindow extends BrowserWindowLike = BrowserWindowLike>(
|
||||
input: OverlayGeometryInput<TWindow>,
|
||||
): OverlayGeometryRuntime<TWindow> {
|
||||
let lastOverlayWindowGeometry: WindowGeometry | null = null;
|
||||
|
||||
const getOverlayGeometryFallback = (): WindowGeometry => {
|
||||
const cursorPoint = input.screen.getCursorScreenPoint();
|
||||
const display = input.screen.getDisplayNearestPoint(cursorPoint);
|
||||
const bounds = display.workArea;
|
||||
return {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
};
|
||||
};
|
||||
|
||||
const getCurrentOverlayGeometry = (): WindowGeometry => {
|
||||
if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry;
|
||||
const trackerGeometry = input.getWindowTracker()?.getGeometry?.() ?? null;
|
||||
if (trackerGeometry) return trackerGeometry;
|
||||
return getOverlayGeometryFallback();
|
||||
};
|
||||
|
||||
const geometryMatches = (a: WindowGeometry | null, b: WindowGeometry | null): boolean => {
|
||||
if (!a || !b) return false;
|
||||
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
};
|
||||
|
||||
const applyOverlayRegions = (geometry: WindowGeometry): void => {
|
||||
lastOverlayWindowGeometry = geometry;
|
||||
input.windowState.setOverlayWindowBounds(geometry);
|
||||
input.windowState.setModalWindowBounds(geometry);
|
||||
};
|
||||
|
||||
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
||||
})(),
|
||||
);
|
||||
|
||||
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
|
||||
createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
||||
ensureOverlayWindowLevelCore: (window) =>
|
||||
input.ensureOverlayWindowLevelCore(window as TWindow),
|
||||
})(),
|
||||
);
|
||||
|
||||
const syncPrimaryOverlayWindowLayer = (layer: 'visible'): void => {
|
||||
const mainWindow = input.windowState.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
input.syncOverlayWindowLayer(mainWindow, layer);
|
||||
};
|
||||
|
||||
const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
|
||||
createBuildEnforceOverlayLayerOrderMainDepsHandler({
|
||||
enforceOverlayLayerOrderCore: (params) =>
|
||||
input.enforceOverlayLayerOrderCore({
|
||||
visibleOverlayVisible: params.visibleOverlayVisible,
|
||||
mainWindow: params.mainWindow as TWindow | null,
|
||||
ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window as TWindow),
|
||||
}),
|
||||
getVisibleOverlayVisible: () => input.windowState.getVisibleOverlayVisible(),
|
||||
getMainWindow: () => input.windowState.getMainWindow(),
|
||||
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as TWindow),
|
||||
})(),
|
||||
);
|
||||
|
||||
return {
|
||||
getLastOverlayWindowGeometry: () => lastOverlayWindowGeometry,
|
||||
getOverlayGeometryFallback,
|
||||
getCurrentOverlayGeometry,
|
||||
geometryMatches,
|
||||
applyOverlayRegions,
|
||||
updateVisibleOverlayBounds,
|
||||
ensureOverlayWindowLevel,
|
||||
syncPrimaryOverlayWindowLayer,
|
||||
enforceOverlayLayerOrder,
|
||||
};
|
||||
}
|
||||
251
src/main/overlay-ui-bootstrap-from-main-state.ts
Normal file
251
src/main/overlay-ui-bootstrap-from-main-state.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
nativeImage,
|
||||
Tray,
|
||||
type BrowserWindow,
|
||||
type MenuItemConstructorOptions,
|
||||
} from 'electron';
|
||||
|
||||
import type { AnilistRuntime } from './anilist-runtime';
|
||||
import type { DictionarySupportRuntime } from './dictionary-support-runtime';
|
||||
import type { FirstRunRuntime } from './first-run-runtime';
|
||||
import type { JellyfinRuntime } from './jellyfin-runtime';
|
||||
import type { MpvRuntime } from './mpv-runtime';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
createOverlayWindow as createOverlayWindowCore,
|
||||
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
|
||||
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
|
||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
syncOverlayWindowLayer,
|
||||
} from '../core/services';
|
||||
import {
|
||||
buildTrayMenuTemplateRuntime,
|
||||
resolveTrayIconPathRuntime,
|
||||
} from './runtime/domains/overlay';
|
||||
import {
|
||||
createOverlayUiBootstrapRuntime,
|
||||
type OverlayUiBootstrapInput,
|
||||
type OverlayUiBootstrapRuntime,
|
||||
} from './overlay-ui-bootstrap-runtime';
|
||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
||||
import type { ShortcutsRuntimeBootstrap } from './shortcuts-runtime';
|
||||
import { createWindowTracker as createWindowTrackerCore } from '../window-trackers';
|
||||
|
||||
export interface OverlayUiBootstrapFromMainStateInput<
|
||||
TWindow extends BrowserWindow,
|
||||
TMenuItem = MenuItemConstructorOptions | MenuItem,
|
||||
> {
|
||||
appState: OverlayUiBootstrapInput<TWindow>['appState'];
|
||||
overlayManager: OverlayUiBootstrapInput<TWindow>['overlayManager'];
|
||||
overlayModalInputState: OverlayUiBootstrapInput<TWindow>['overlayModalInputState'];
|
||||
overlayModalRuntime: OverlayModalRuntime;
|
||||
overlayShortcutsRuntime: ShortcutsRuntimeBootstrap['overlayShortcutsRuntime'];
|
||||
runtimes: {
|
||||
dictionarySupport: Pick<DictionarySupportRuntime, 'createFieldGroupingCallback'>;
|
||||
firstRun: Pick<FirstRunRuntime, 'isSetupCompleted' | 'openFirstRunSetupWindow'>;
|
||||
yomitan: {
|
||||
openYomitanSettings: () => boolean;
|
||||
};
|
||||
jellyfin: Pick<JellyfinRuntime, 'openJellyfinSetupWindow'>;
|
||||
anilist: Pick<AnilistRuntime, 'openAnilistSetupWindow'>;
|
||||
shortcuts: Pick<ShortcutsRuntimeBootstrap['shortcuts'], 'registerGlobalShortcuts'>;
|
||||
mpvRuntime: Pick<MpvRuntime, 'startBackgroundWarmups'>;
|
||||
};
|
||||
electron: OverlayUiBootstrapInput<TWindow>['electron'] & {
|
||||
buildMenuFromTemplate: (template: TMenuItem[]) => unknown;
|
||||
createTray: (
|
||||
icon: ReturnType<OverlayUiBootstrapInput<TWindow>['electron']['createEmptyImage']>,
|
||||
) => Tray;
|
||||
};
|
||||
windowing: OverlayUiBootstrapInput<TWindow>['windowing'];
|
||||
actions: Omit<
|
||||
OverlayUiBootstrapInput<TWindow>['actions'],
|
||||
'registerGlobalShortcuts' | 'startBackgroundWarmups'
|
||||
>;
|
||||
trayState: OverlayUiBootstrapInput<TWindow>['trayState'];
|
||||
startup: OverlayUiBootstrapInput<TWindow>['startup'];
|
||||
}
|
||||
|
||||
export function createOverlayUiBootstrapFromMainState<TWindow extends BrowserWindow>(
|
||||
input: OverlayUiBootstrapFromMainStateInput<TWindow>,
|
||||
): OverlayUiBootstrapRuntime<TWindow> {
|
||||
return createOverlayUiBootstrapRuntime<TWindow>({
|
||||
appState: input.appState,
|
||||
overlayManager: input.overlayManager,
|
||||
overlayModalInputState: input.overlayModalInputState,
|
||||
overlayModalRuntime: input.overlayModalRuntime,
|
||||
overlayShortcutsRuntime: input.overlayShortcutsRuntime,
|
||||
dictionarySupport: {
|
||||
createFieldGroupingCallback: () =>
|
||||
input.runtimes.dictionarySupport.createFieldGroupingCallback(),
|
||||
},
|
||||
firstRun: {
|
||||
isSetupCompleted: () => input.runtimes.firstRun.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => input.runtimes.firstRun.openFirstRunSetupWindow(),
|
||||
},
|
||||
yomitan: {
|
||||
openYomitanSettings: () => {
|
||||
input.runtimes.yomitan.openYomitanSettings();
|
||||
},
|
||||
},
|
||||
jellyfin: {
|
||||
openJellyfinSetupWindow: () => input.runtimes.jellyfin.openJellyfinSetupWindow(),
|
||||
},
|
||||
anilist: {
|
||||
openAnilistSetupWindow: () => input.runtimes.anilist.openAnilistSetupWindow(),
|
||||
},
|
||||
electron: input.electron,
|
||||
windowing: input.windowing,
|
||||
actions: {
|
||||
...input.actions,
|
||||
registerGlobalShortcuts: () => input.runtimes.shortcuts.registerGlobalShortcuts(),
|
||||
startBackgroundWarmups: () => input.runtimes.mpvRuntime.startBackgroundWarmups(),
|
||||
},
|
||||
trayState: input.trayState,
|
||||
startup: input.startup,
|
||||
});
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapCoordinatorInput<TWindow extends BrowserWindow> {
|
||||
appState: OverlayUiBootstrapFromMainStateInput<TWindow>['appState'];
|
||||
overlayManager: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayManager'];
|
||||
overlayModalInputState: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayModalInputState'];
|
||||
overlayModalRuntime: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayModalRuntime'];
|
||||
overlayShortcutsRuntime: OverlayUiBootstrapFromMainStateInput<TWindow>['overlayShortcutsRuntime'];
|
||||
runtimes: OverlayUiBootstrapFromMainStateInput<TWindow>['runtimes'];
|
||||
env: {
|
||||
screen: OverlayUiBootstrapFromMainStateInput<TWindow>['electron']['screen'];
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
dirname: string;
|
||||
platform: NodeJS.Platform;
|
||||
};
|
||||
windowing: OverlayUiBootstrapFromMainStateInput<TWindow>['windowing'];
|
||||
actions: Omit<
|
||||
OverlayUiBootstrapFromMainStateInput<TWindow>['actions'],
|
||||
| 'resolveTrayIconPathRuntime'
|
||||
| 'buildTrayMenuTemplateRuntime'
|
||||
| 'broadcastRuntimeOptionsChangedRuntime'
|
||||
| 'setOverlayDebugVisualizationEnabledRuntime'
|
||||
| 'initializeOverlayRuntimeCore'
|
||||
> &
|
||||
Pick<
|
||||
OverlayUiBootstrapFromMainStateInput<TWindow>['actions'],
|
||||
| 'resolveTrayIconPathRuntime'
|
||||
| 'buildTrayMenuTemplateRuntime'
|
||||
| 'broadcastRuntimeOptionsChangedRuntime'
|
||||
| 'setOverlayDebugVisualizationEnabledRuntime'
|
||||
| 'initializeOverlayRuntimeCore'
|
||||
>;
|
||||
trayState: OverlayUiBootstrapFromMainStateInput<TWindow>['trayState'];
|
||||
startup: OverlayUiBootstrapFromMainStateInput<TWindow>['startup'];
|
||||
}
|
||||
|
||||
export function createOverlayUiBootstrapCoordinator<TWindow extends BrowserWindow>(
|
||||
input: OverlayUiBootstrapCoordinatorInput<TWindow>,
|
||||
): OverlayUiBootstrapRuntime<TWindow> {
|
||||
return createOverlayUiBootstrapFromMainState<TWindow>({
|
||||
appState: input.appState,
|
||||
overlayManager: input.overlayManager,
|
||||
overlayModalInputState: input.overlayModalInputState,
|
||||
overlayModalRuntime: input.overlayModalRuntime,
|
||||
overlayShortcutsRuntime: input.overlayShortcutsRuntime,
|
||||
runtimes: input.runtimes,
|
||||
electron: {
|
||||
screen: input.env.screen,
|
||||
appPath: input.env.appPath,
|
||||
resourcesPath: input.env.resourcesPath,
|
||||
dirname: input.env.dirname,
|
||||
platform: input.env.platform,
|
||||
joinPath: (...parts) => path.join(...parts),
|
||||
fileExists: (candidate) => fs.existsSync(candidate),
|
||||
createImageFromPath: (iconPath) => nativeImage.createFromPath(iconPath),
|
||||
createEmptyImage: () => nativeImage.createEmpty(),
|
||||
createTray: (icon) => new Tray(icon as ConstructorParameters<typeof Tray>[0]),
|
||||
buildMenuFromTemplate: (template) =>
|
||||
Menu.buildFromTemplate(template as (MenuItemConstructorOptions | MenuItem)[]),
|
||||
},
|
||||
windowing: input.windowing,
|
||||
actions: input.actions,
|
||||
trayState: input.trayState,
|
||||
startup: input.startup,
|
||||
});
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapFromProcessStateInput<TWindow extends BrowserWindow> {
|
||||
appState: OverlayUiBootstrapCoordinatorInput<TWindow>['appState'];
|
||||
overlayManager: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayManager'];
|
||||
overlayModalInputState: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayModalInputState'];
|
||||
overlayModalRuntime: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayModalRuntime'];
|
||||
overlayShortcutsRuntime: OverlayUiBootstrapCoordinatorInput<TWindow>['overlayShortcutsRuntime'];
|
||||
runtimes: OverlayUiBootstrapCoordinatorInput<TWindow>['runtimes'];
|
||||
env: OverlayUiBootstrapCoordinatorInput<TWindow>['env'] & {
|
||||
isDev: boolean;
|
||||
};
|
||||
actions: {
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
ensureOverlayMpvSubtitlesHidden: () => Promise<void>;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
requestAppQuit: () => void;
|
||||
};
|
||||
trayState: OverlayUiBootstrapCoordinatorInput<TWindow>['trayState'];
|
||||
startup: OverlayUiBootstrapCoordinatorInput<TWindow>['startup'];
|
||||
}
|
||||
|
||||
export function createOverlayUiBootstrapFromProcessState<TWindow extends BrowserWindow>(
|
||||
input: OverlayUiBootstrapFromProcessStateInput<TWindow>,
|
||||
): OverlayUiBootstrapRuntime<TWindow> {
|
||||
return createOverlayUiBootstrapCoordinator({
|
||||
appState: input.appState,
|
||||
overlayManager: input.overlayManager,
|
||||
overlayModalInputState: input.overlayModalInputState,
|
||||
overlayModalRuntime: input.overlayModalRuntime,
|
||||
overlayShortcutsRuntime: input.overlayShortcutsRuntime,
|
||||
runtimes: input.runtimes,
|
||||
env: input.env,
|
||||
windowing: {
|
||||
isDev: input.env.isDev,
|
||||
createOverlayWindowCore: (kind, options) =>
|
||||
createOverlayWindowCore(kind, options as never) as TWindow,
|
||||
ensureOverlayWindowLevelCore: (window) =>
|
||||
ensureOverlayWindowLevelCore(window as BrowserWindow),
|
||||
syncOverlayWindowLayer: (window, layer) =>
|
||||
syncOverlayWindowLayer(window as BrowserWindow, layer),
|
||||
enforceOverlayLayerOrderCore: (params) =>
|
||||
enforceOverlayLayerOrderCore({
|
||||
...params,
|
||||
mainWindow: params.mainWindow as BrowserWindow | null,
|
||||
ensureOverlayWindowLevel: (window) => params.ensureOverlayWindowLevel(window as TWindow),
|
||||
}),
|
||||
createWindowTrackerCore: (override, targetMpvSocketPath) =>
|
||||
createWindowTrackerCore(override, targetMpvSocketPath),
|
||||
},
|
||||
actions: {
|
||||
showMpvOsd: (message) => input.actions.showMpvOsd(message),
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.actions.showDesktopNotification(title, options),
|
||||
sendMpvCommand: (command) => input.actions.sendMpvCommand(command),
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
resolveTrayIconPathRuntime,
|
||||
buildTrayMenuTemplateRuntime,
|
||||
initializeOverlayRuntimeCore: (options) => initializeOverlayRuntimeCore(options as never),
|
||||
ensureOverlayMpvSubtitlesHidden: () => input.actions.ensureOverlayMpvSubtitlesHidden(),
|
||||
syncOverlayMpvSubtitleSuppression: () => input.actions.syncOverlayMpvSubtitleSuppression(),
|
||||
getResolvedConfig: () => input.actions.getResolvedConfig(),
|
||||
requestAppQuit: input.actions.requestAppQuit,
|
||||
},
|
||||
trayState: input.trayState,
|
||||
startup: input.startup,
|
||||
});
|
||||
}
|
||||
133
src/main/overlay-ui-bootstrap-runtime-input.test.ts
Normal file
133
src/main/overlay-ui-bootstrap-runtime-input.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
|
||||
import { createOverlayUiBootstrapRuntimeInput } from './overlay-ui-bootstrap-runtime-input';
|
||||
|
||||
test('overlay ui bootstrap runtime input builder preserves grouped wiring', () => {
|
||||
const input = createOverlayUiBootstrapRuntimeInput({
|
||||
windows: {
|
||||
state: {
|
||||
getMainWindow: () => null,
|
||||
setMainWindow: () => {},
|
||||
getModalWindow: () => null,
|
||||
setModalWindow: () => {},
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getOverlayDebugVisualizationEnabled: () => false,
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
},
|
||||
geometry: {
|
||||
getCurrentOverlayGeometry: () => ({ x: 1, y: 2, width: 3, height: 4 }),
|
||||
},
|
||||
modal: {
|
||||
setModalWindowBounds: () => {},
|
||||
onModalStateChange: () => {},
|
||||
},
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: (_modal: OverlayHostedModal) => {},
|
||||
notifyOverlayModalOpened: (_modal: OverlayHostedModal) => {},
|
||||
waitForModalOpen: async () => true,
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
sendToActiveOverlayWindow: () => false,
|
||||
},
|
||||
visibility: {
|
||||
service: {
|
||||
getModalActive: () => false,
|
||||
getForceMousePassthrough: () => false,
|
||||
getWindowTracker: () => null,
|
||||
getTrackerNotReadyWarningShown: () => false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
syncPrimaryOverlayWindowLayer: () => {},
|
||||
enforceOverlayLayerOrder: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: () => false,
|
||||
isWindowsPlatform: () => false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 5, y: 6, width: 7, height: 8 }),
|
||||
},
|
||||
overlayWindows: {
|
||||
createOverlayWindowCore: () => ({ isDestroyed: () => false }),
|
||||
isDev: false,
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
onRuntimeOptionsChanged: () => {},
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
isOverlayVisible: () => false,
|
||||
getYomitanSession: () => null,
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => {},
|
||||
onWindowClosed: () => {},
|
||||
},
|
||||
actions: {
|
||||
setVisibleOverlayVisibleCore: () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
overlayActions: {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getMpvClient: () => null,
|
||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
||||
},
|
||||
tray: null,
|
||||
bootstrap: {
|
||||
initializeOverlayRuntimeMainDeps: {
|
||||
appState: {
|
||||
backendOverride: null,
|
||||
windowTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
mpvClient: null,
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
runtimeOptionsManager: null,
|
||||
ankiIntegration: null,
|
||||
},
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => false,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => {},
|
||||
},
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
initializeOverlayRuntimeCore: () => {},
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
},
|
||||
onInitialized: () => {},
|
||||
},
|
||||
runtimeState: {
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
},
|
||||
mpvSubtitle: {
|
||||
ensureOverlayMpvSubtitlesHidden: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(input.tray, null);
|
||||
assert.equal(input.windows.windowState.getMainWindow(), null);
|
||||
assert.equal(input.windows.geometry.getCurrentOverlayGeometry().width, 3);
|
||||
assert.equal(input.windows.visibilityService.resolveFallbackBounds().height, 8);
|
||||
assert.equal(
|
||||
input.bootstrap.initializeOverlayRuntimeBootstrapDeps.isOverlayRuntimeInitialized(),
|
||||
false,
|
||||
);
|
||||
});
|
||||
87
src/main/overlay-ui-bootstrap-runtime-input.ts
Normal file
87
src/main/overlay-ui-bootstrap-runtime-input.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type {
|
||||
OverlayUiActionsInput,
|
||||
OverlayUiBootstrapInput,
|
||||
OverlayUiGeometryInput,
|
||||
OverlayUiModalInput,
|
||||
OverlayUiMpvSubtitleInput,
|
||||
OverlayUiRuntimeStateInput,
|
||||
OverlayUiTrayInput,
|
||||
OverlayUiVisibilityActionsInput,
|
||||
OverlayUiVisibilityServiceInput,
|
||||
OverlayUiWindowState,
|
||||
OverlayUiWindowsInput,
|
||||
} from './overlay-ui-runtime';
|
||||
import type { OverlayUiRuntimeGroupedInput } from './overlay-ui-runtime-input';
|
||||
|
||||
type WindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
export interface OverlayUiBootstrapRuntimeWindowsInput<TWindow extends WindowLike = WindowLike> {
|
||||
state: OverlayUiWindowState<TWindow>;
|
||||
geometry: OverlayUiGeometryInput;
|
||||
modal: OverlayUiModalInput;
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
};
|
||||
visibility: {
|
||||
service: OverlayUiVisibilityServiceInput<TWindow>;
|
||||
overlayWindows: OverlayUiWindowsInput<TWindow>;
|
||||
actions: OverlayUiVisibilityActionsInput;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapRuntimeInput<TWindow extends WindowLike = WindowLike> {
|
||||
windows: OverlayUiBootstrapRuntimeWindowsInput<TWindow>;
|
||||
overlayActions: OverlayUiActionsInput;
|
||||
tray: OverlayUiTrayInput | null;
|
||||
bootstrap: OverlayUiBootstrapInput;
|
||||
runtimeState: OverlayUiRuntimeStateInput;
|
||||
mpvSubtitle: OverlayUiMpvSubtitleInput;
|
||||
}
|
||||
|
||||
export function createOverlayUiBootstrapRuntimeInput<TWindow extends WindowLike>(
|
||||
input: OverlayUiBootstrapRuntimeInput<TWindow>,
|
||||
): OverlayUiRuntimeGroupedInput<TWindow> {
|
||||
return {
|
||||
windows: {
|
||||
windowState: input.windows.state,
|
||||
geometry: input.windows.geometry,
|
||||
modal: input.windows.modal,
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: (modal) =>
|
||||
input.windows.modalRuntime.handleOverlayModalClosed(modal),
|
||||
notifyOverlayModalOpened: (modal) =>
|
||||
input.windows.modalRuntime.notifyOverlayModalOpened(modal),
|
||||
waitForModalOpen: (modal, timeoutMs) =>
|
||||
input.windows.modalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
input.windows.modalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
||||
openRuntimeOptionsPalette: () => input.windows.modalRuntime.openRuntimeOptionsPalette(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
input.windows.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
},
|
||||
visibilityService: input.windows.visibility.service,
|
||||
overlayWindows: input.windows.visibility.overlayWindows,
|
||||
visibilityActions: input.windows.visibility.actions,
|
||||
},
|
||||
overlayActions: input.overlayActions,
|
||||
tray: input.tray,
|
||||
bootstrap: input.bootstrap,
|
||||
runtimeState: input.runtimeState,
|
||||
mpvSubtitle: input.mpvSubtitle,
|
||||
};
|
||||
}
|
||||
503
src/main/overlay-ui-bootstrap-runtime.ts
Normal file
503
src/main/overlay-ui-bootstrap-runtime.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
import type { BrowserWindow, Session } from 'electron';
|
||||
import type {
|
||||
AnkiConnectConfig,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuFieldGroupingRequestData,
|
||||
RuntimeOptionState,
|
||||
WindowGeometry,
|
||||
} from '../types';
|
||||
import type { BaseWindowTracker } from '../window-trackers';
|
||||
import {
|
||||
createOverlayGeometryRuntime,
|
||||
type OverlayGeometryRuntime,
|
||||
} from './overlay-geometry-runtime';
|
||||
import { createOverlayUiBootstrapRuntimeInput } from './overlay-ui-bootstrap-runtime-input';
|
||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
||||
import { createOverlayUiRuntime, type OverlayUiRuntime } from './overlay-ui-runtime';
|
||||
|
||||
type WindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
type OverlayWindowKind = 'visible' | 'modal';
|
||||
|
||||
type ScreenLike = {
|
||||
getCursorScreenPoint: () => { x: number; y: number };
|
||||
getDisplayNearestPoint: (point: { x: number; y: number }) => {
|
||||
workArea: { x: number; y: number; width: number; height: number };
|
||||
};
|
||||
};
|
||||
|
||||
type OverlayWindowTrackerLike = BaseWindowTracker | null;
|
||||
|
||||
type OverlayRuntimeOptionsManagerLike = {
|
||||
listOptions: () => RuntimeOptionState[];
|
||||
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
||||
} | null;
|
||||
|
||||
type OverlayMpvClientLike = {
|
||||
connected: boolean;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
send?: (payload: { command: string[] }) => void;
|
||||
} | null;
|
||||
|
||||
type OverlayManagerLike<TWindow extends WindowLike> = {
|
||||
getMainWindow: () => TWindow | null;
|
||||
setMainWindow: (window: TWindow | null) => void;
|
||||
getModalWindow: () => TWindow | null;
|
||||
setModalWindow: (window: TWindow | null) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
||||
getOverlayWindows: () => BrowserWindow[];
|
||||
};
|
||||
|
||||
type OverlayModalInputStateLike = {
|
||||
getModalInputExclusive: () => boolean;
|
||||
handleModalInputStateChange: (active: boolean) => void;
|
||||
};
|
||||
|
||||
type OverlayShortcutsRuntimeLike = {
|
||||
syncOverlayShortcuts: () => void;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
};
|
||||
|
||||
type DictionarySupportLike = {
|
||||
createFieldGroupingCallback: () => (
|
||||
data: KikuFieldGroupingRequestData,
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
};
|
||||
|
||||
type FirstRunLike = {
|
||||
isSetupCompleted: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
};
|
||||
|
||||
type YomitanLike = {
|
||||
openYomitanSettings: () => void;
|
||||
};
|
||||
|
||||
type JellyfinLike = {
|
||||
openJellyfinSetupWindow: () => void;
|
||||
};
|
||||
|
||||
type AnilistLike = {
|
||||
openAnilistSetupWindow: () => void;
|
||||
};
|
||||
|
||||
type BootstrapTrayIconLike = {
|
||||
isEmpty: () => boolean;
|
||||
resize: (options: {
|
||||
width: number;
|
||||
height: number;
|
||||
quality?: 'best' | 'better' | 'good';
|
||||
}) => BootstrapTrayIconLike;
|
||||
setTemplateImage: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
type BootstrapTrayLike = {
|
||||
setContextMenu: (menu: any) => void;
|
||||
setToolTip: (tooltip: string) => void;
|
||||
on: (event: 'click', handler: () => void) => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
export interface OverlayUiBootstrapAppStateInput {
|
||||
backendOverride: string | null;
|
||||
windowTracker: OverlayWindowTrackerLike;
|
||||
subtitleTimingTracker: unknown;
|
||||
mpvClient: OverlayMpvClientLike;
|
||||
mpvSocketPath: string;
|
||||
runtimeOptionsManager: OverlayRuntimeOptionsManagerLike;
|
||||
ankiIntegration: unknown;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
overlayDebugVisualizationEnabled: boolean;
|
||||
statsOverlayVisible: boolean;
|
||||
trackerNotReadyWarningShown: boolean;
|
||||
yomitanSession: Session | null;
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapElectronInput<
|
||||
TWindow extends WindowLike,
|
||||
TMenuItem = unknown,
|
||||
TMenu = unknown,
|
||||
> {
|
||||
screen: ScreenLike;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
dirname: string;
|
||||
platform: NodeJS.Platform;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
fileExists: (candidate: string) => boolean;
|
||||
createImageFromPath: (iconPath: string) => BootstrapTrayIconLike;
|
||||
createEmptyImage: () => BootstrapTrayIconLike;
|
||||
createTray: (icon: BootstrapTrayIconLike) => BootstrapTrayLike;
|
||||
buildMenuFromTemplate: (template: TMenuItem[]) => TMenu;
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapInput<TWindow extends WindowLike> {
|
||||
appState: OverlayUiBootstrapAppStateInput;
|
||||
overlayManager: OverlayManagerLike<TWindow>;
|
||||
overlayModalInputState: OverlayModalInputStateLike;
|
||||
overlayModalRuntime: OverlayModalRuntime;
|
||||
overlayShortcutsRuntime: OverlayShortcutsRuntimeLike;
|
||||
dictionarySupport: DictionarySupportLike;
|
||||
firstRun: FirstRunLike;
|
||||
yomitan: YomitanLike;
|
||||
jellyfin: JellyfinLike;
|
||||
anilist: AnilistLike;
|
||||
electron: OverlayUiBootstrapElectronInput<TWindow>;
|
||||
windowing: {
|
||||
isDev: boolean;
|
||||
createOverlayWindowCore: (
|
||||
kind: OverlayWindowKind,
|
||||
options: {
|
||||
isDev: boolean;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
onRuntimeOptionsChanged: () => void;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
yomitanSession?: Electron.Session | null;
|
||||
},
|
||||
) => TWindow;
|
||||
ensureOverlayWindowLevelCore: (window: TWindow) => void;
|
||||
syncOverlayWindowLayer: (window: TWindow, layer: 'visible') => void;
|
||||
enforceOverlayLayerOrderCore: (params: {
|
||||
visibleOverlayVisible: boolean;
|
||||
mainWindow: TWindow | null;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
}) => void;
|
||||
createWindowTrackerCore: (
|
||||
override?: string | null,
|
||||
targetMpvSocketPath?: string | null,
|
||||
) => BaseWindowTracker | null;
|
||||
};
|
||||
actions: {
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
broadcastRuntimeOptionsChangedRuntime: (
|
||||
getRuntimeOptionsState: () => RuntimeOptionState[],
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
|
||||
) => void;
|
||||
setOverlayDebugVisualizationEnabledRuntime: (
|
||||
currentEnabled: boolean,
|
||||
nextEnabled: boolean,
|
||||
setCurrentEnabled: (enabled: boolean) => void,
|
||||
) => void;
|
||||
resolveTrayIconPathRuntime: (options: {
|
||||
platform: string;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
dirname: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
fileExists: (path: string) => boolean;
|
||||
}) => string | null;
|
||||
buildTrayMenuTemplateRuntime: (handlers: {
|
||||
openOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
openAnilistSetup: () => void;
|
||||
quitApp: () => void;
|
||||
}) => unknown[];
|
||||
initializeOverlayRuntimeCore: (options: unknown) => void;
|
||||
ensureOverlayMpvSubtitlesHidden: () => Promise<void> | void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
registerGlobalShortcuts: () => void;
|
||||
startBackgroundWarmups: () => void;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
||||
requestAppQuit: () => void;
|
||||
};
|
||||
trayState: {
|
||||
getTray: () => BootstrapTrayLike | null;
|
||||
setTray: (tray: BootstrapTrayLike | null) => void;
|
||||
trayTooltip: string;
|
||||
logWarn: (message: string) => void;
|
||||
};
|
||||
startup: {
|
||||
shouldSkipHeadlessOverlayBootstrap: () => boolean;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
onInitialized?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapRuntime<TWindow extends WindowLike> {
|
||||
overlayGeometry: OverlayGeometryRuntime<TWindow>;
|
||||
overlayUi: OverlayUiRuntime<TWindow>;
|
||||
syncOverlayVisibilityForModal: () => void;
|
||||
}
|
||||
|
||||
export function createOverlayUiBootstrapRuntime<TWindow extends WindowLike>(
|
||||
input: OverlayUiBootstrapInput<TWindow>,
|
||||
): OverlayUiBootstrapRuntime<TWindow> {
|
||||
const overlayGeometry = createOverlayGeometryRuntime<TWindow>({
|
||||
screen: input.electron.screen,
|
||||
windowState: {
|
||||
getMainWindow: () => input.overlayManager.getMainWindow(),
|
||||
setOverlayWindowBounds: (geometry) => input.overlayManager.setOverlayWindowBounds(geometry),
|
||||
setModalWindowBounds: (geometry) => input.overlayManager.setModalWindowBounds(geometry),
|
||||
getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(),
|
||||
},
|
||||
getWindowTracker: () => input.appState.windowTracker,
|
||||
ensureOverlayWindowLevelCore: (window) => input.windowing.ensureOverlayWindowLevelCore(window),
|
||||
syncOverlayWindowLayer: (window, layer) =>
|
||||
input.windowing.syncOverlayWindowLayer(window, layer),
|
||||
enforceOverlayLayerOrderCore: (params) => input.windowing.enforceOverlayLayerOrderCore(params),
|
||||
});
|
||||
|
||||
let overlayUi: OverlayUiRuntime<TWindow> | undefined;
|
||||
|
||||
overlayUi = createOverlayUiRuntime(
|
||||
createOverlayUiBootstrapRuntimeInput<TWindow>({
|
||||
windows: {
|
||||
state: {
|
||||
getMainWindow: () => input.overlayManager.getMainWindow(),
|
||||
setMainWindow: (window) => input.overlayManager.setMainWindow(window),
|
||||
getModalWindow: () => input.overlayManager.getModalWindow(),
|
||||
setModalWindow: (window) => input.overlayManager.setModalWindow(window),
|
||||
getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(),
|
||||
setVisibleOverlayVisible: (visible) =>
|
||||
input.overlayManager.setVisibleOverlayVisible(visible),
|
||||
getOverlayDebugVisualizationEnabled: () =>
|
||||
input.appState.overlayDebugVisualizationEnabled,
|
||||
setOverlayDebugVisualizationEnabled: (enabled) => {
|
||||
input.appState.overlayDebugVisualizationEnabled = enabled;
|
||||
},
|
||||
},
|
||||
geometry: {
|
||||
getCurrentOverlayGeometry: () => overlayGeometry.getCurrentOverlayGeometry(),
|
||||
},
|
||||
modal: {
|
||||
setModalWindowBounds: (geometry) => input.overlayManager.setModalWindowBounds(geometry),
|
||||
onModalStateChange: (active) => {
|
||||
input.overlayModalInputState.handleModalInputStateChange(active);
|
||||
},
|
||||
},
|
||||
modalRuntime: input.overlayModalRuntime as never,
|
||||
visibility: {
|
||||
service: {
|
||||
getModalActive: () => input.overlayModalInputState.getModalInputExclusive(),
|
||||
getForceMousePassthrough: () => input.appState.statsOverlayVisible,
|
||||
getWindowTracker: () => input.appState.windowTracker,
|
||||
getTrackerNotReadyWarningShown: () => input.appState.trackerNotReadyWarningShown,
|
||||
setTrackerNotReadyWarningShown: (shown) => {
|
||||
input.appState.trackerNotReadyWarningShown = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: (geometry) =>
|
||||
overlayGeometry.updateVisibleOverlayBounds(geometry),
|
||||
ensureOverlayWindowLevel: (window) => overlayGeometry.ensureOverlayWindowLevel(window),
|
||||
syncPrimaryOverlayWindowLayer: (layer) =>
|
||||
overlayGeometry.syncPrimaryOverlayWindowLayer(layer),
|
||||
enforceOverlayLayerOrder: () => overlayGeometry.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => input.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
isMacOSPlatform: () => input.electron.platform === 'darwin',
|
||||
isWindowsPlatform: () => input.electron.platform === 'win32',
|
||||
showOverlayLoadingOsd: (message) => input.actions.showMpvOsd(message),
|
||||
resolveFallbackBounds: () => overlayGeometry.getOverlayGeometryFallback(),
|
||||
},
|
||||
overlayWindows: {
|
||||
createOverlayWindowCore: (kind, options) =>
|
||||
input.windowing.createOverlayWindowCore(kind, options),
|
||||
isDev: input.windowing.isDev,
|
||||
ensureOverlayWindowLevel: (window) => overlayGeometry.ensureOverlayWindowLevel(window),
|
||||
onRuntimeOptionsChanged: () => {
|
||||
overlayUi?.broadcastRuntimeOptionsChanged();
|
||||
},
|
||||
setOverlayDebugVisualizationEnabled: (enabled) => {
|
||||
overlayUi?.setOverlayDebugVisualizationEnabled(enabled);
|
||||
},
|
||||
isOverlayVisible: (windowKind) =>
|
||||
windowKind === 'visible' ? input.overlayManager.getVisibleOverlayVisible() : false,
|
||||
getYomitanSession: () => input.appState.yomitanSession,
|
||||
tryHandleOverlayShortcutLocalFallback: (overlayInput) =>
|
||||
input.overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(overlayInput),
|
||||
forwardTabToMpv: () => input.actions.sendMpvCommand(['keypress', 'TAB']),
|
||||
onWindowClosed: (windowKind) => {
|
||||
if (windowKind === 'visible') {
|
||||
input.overlayManager.setMainWindow(null);
|
||||
return;
|
||||
}
|
||||
input.overlayManager.setModalWindow(null);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setVisibleOverlayVisibleCore: ({
|
||||
visible,
|
||||
setVisibleOverlayVisibleState,
|
||||
updateVisibleOverlayVisibility,
|
||||
}) => {
|
||||
setVisibleOverlayVisibleState(visible);
|
||||
updateVisibleOverlayVisibility();
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
overlayActions: {
|
||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
broadcastRuntimeOptionsChangedRuntime: (
|
||||
getRuntimeOptionsState,
|
||||
broadcastToOverlayWindows,
|
||||
) =>
|
||||
input.actions.broadcastRuntimeOptionsChangedRuntime(
|
||||
getRuntimeOptionsState,
|
||||
broadcastToOverlayWindows,
|
||||
),
|
||||
broadcastToOverlayWindows: (channel, ...args) =>
|
||||
input.overlayManager.broadcastToOverlayWindows(channel, ...args),
|
||||
setOverlayDebugVisualizationEnabledRuntime: (
|
||||
currentEnabled,
|
||||
nextEnabled,
|
||||
setCurrentEnabled,
|
||||
) =>
|
||||
input.actions.setOverlayDebugVisualizationEnabledRuntime(
|
||||
currentEnabled,
|
||||
nextEnabled,
|
||||
setCurrentEnabled,
|
||||
),
|
||||
},
|
||||
tray: {
|
||||
resolveTrayIconPathDeps: {
|
||||
resolveTrayIconPathRuntime: input.actions.resolveTrayIconPathRuntime,
|
||||
platform: input.electron.platform,
|
||||
resourcesPath: input.electron.resourcesPath,
|
||||
appPath: input.electron.appPath,
|
||||
dirname: input.electron.dirname,
|
||||
joinPath: (...parts) => input.electron.joinPath(...parts),
|
||||
fileExists: (candidate) => input.electron.fileExists(candidate),
|
||||
},
|
||||
buildTrayMenuTemplateDeps: {
|
||||
buildTrayMenuTemplateRuntime: input.actions.buildTrayMenuTemplateRuntime,
|
||||
initializeOverlayRuntime: () => {
|
||||
overlayUi?.initializeOverlayRuntime();
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
overlayUi?.setVisibleOverlayVisible(visible);
|
||||
},
|
||||
showFirstRunSetup: () => !input.firstRun.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => input.firstRun.openFirstRunSetupWindow(),
|
||||
showWindowsMpvLauncherSetup: () => input.electron.platform === 'win32',
|
||||
openYomitanSettings: () => input.yomitan.openYomitanSettings(),
|
||||
openRuntimeOptionsPalette: () => {
|
||||
overlayUi?.openRuntimeOptionsPalette();
|
||||
},
|
||||
openJellyfinSetupWindow: () => input.jellyfin.openJellyfinSetupWindow(),
|
||||
openAnilistSetupWindow: () => input.anilist.openAnilistSetupWindow(),
|
||||
quitApp: () => input.actions.requestAppQuit(),
|
||||
},
|
||||
ensureTrayDeps: {
|
||||
getTray: () => input.trayState.getTray(),
|
||||
setTray: (tray) => input.trayState.setTray(tray),
|
||||
createImageFromPath: (iconPath) => input.electron.createImageFromPath(iconPath),
|
||||
createEmptyImage: () => input.electron.createEmptyImage(),
|
||||
createTray: (icon) => input.electron.createTray(icon),
|
||||
trayTooltip: input.trayState.trayTooltip,
|
||||
platform: input.electron.platform,
|
||||
logWarn: (message) => input.trayState.logWarn(message),
|
||||
initializeOverlayRuntime: () => {
|
||||
overlayUi?.initializeOverlayRuntime();
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
overlayUi?.setVisibleOverlayVisible(visible);
|
||||
},
|
||||
},
|
||||
destroyTrayDeps: {
|
||||
getTray: () => input.trayState.getTray(),
|
||||
setTray: (tray) => input.trayState.setTray(tray),
|
||||
},
|
||||
buildMenuFromTemplate: (template) => input.electron.buildMenuFromTemplate(template),
|
||||
},
|
||||
bootstrap: {
|
||||
initializeOverlayRuntimeMainDeps: {
|
||||
appState: input.appState,
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => input.overlayManager.getVisibleOverlayVisible(),
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => {
|
||||
overlayUi?.updateVisibleOverlayVisibility();
|
||||
},
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => input.overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
},
|
||||
createMainWindow: () => {
|
||||
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
|
||||
return;
|
||||
}
|
||||
overlayUi?.createMainWindow();
|
||||
},
|
||||
registerGlobalShortcuts: () => {
|
||||
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
|
||||
return;
|
||||
}
|
||||
input.actions.registerGlobalShortcuts();
|
||||
},
|
||||
createWindowTracker: (override, targetMpvSocketPath) => {
|
||||
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
|
||||
return null;
|
||||
}
|
||||
return input.windowing.createWindowTrackerCore(
|
||||
override as string | null | undefined,
|
||||
targetMpvSocketPath as string | null | undefined,
|
||||
);
|
||||
},
|
||||
updateVisibleOverlayBounds: (geometry) =>
|
||||
overlayGeometry.updateVisibleOverlayBounds(geometry),
|
||||
getOverlayWindows: () => input.overlayManager.getOverlayWindows(),
|
||||
getResolvedConfig: () => input.actions.getResolvedConfig(),
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.actions.showDesktopNotification(title, options),
|
||||
createFieldGroupingCallback: () => input.dictionarySupport.createFieldGroupingCallback(),
|
||||
getKnownWordCacheStatePath: () => input.startup.getKnownWordCacheStatePath(),
|
||||
shouldStartAnkiIntegration: () => !input.startup.shouldSkipHeadlessOverlayBootstrap(),
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
initializeOverlayRuntimeCore: (options) =>
|
||||
input.actions.initializeOverlayRuntimeCore(options),
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
input.appState.overlayRuntimeInitialized = initialized;
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
if (input.startup.shouldSkipHeadlessOverlayBootstrap()) {
|
||||
return;
|
||||
}
|
||||
input.actions.startBackgroundWarmups();
|
||||
},
|
||||
},
|
||||
onInitialized: input.startup.onInitialized,
|
||||
},
|
||||
runtimeState: {
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
input.appState.overlayRuntimeInitialized = initialized;
|
||||
},
|
||||
},
|
||||
mpvSubtitle: {
|
||||
ensureOverlayMpvSubtitlesHidden: () => input.actions.ensureOverlayMpvSubtitlesHidden(),
|
||||
syncOverlayMpvSubtitleSuppression: () => input.actions.syncOverlayMpvSubtitleSuppression(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
overlayGeometry,
|
||||
overlayUi,
|
||||
syncOverlayVisibilityForModal: () => {
|
||||
overlayUi.updateVisibleOverlayVisibility();
|
||||
},
|
||||
};
|
||||
}
|
||||
92
src/main/overlay-ui-runtime-input.ts
Normal file
92
src/main/overlay-ui-runtime-input.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import type {
|
||||
OverlayUiActionsInput,
|
||||
OverlayUiBootstrapInput,
|
||||
OverlayUiGeometryInput,
|
||||
OverlayUiModalInput,
|
||||
OverlayUiMpvSubtitleInput,
|
||||
OverlayUiRuntimeInput,
|
||||
OverlayUiRuntimeStateInput,
|
||||
OverlayUiTrayInput,
|
||||
OverlayUiVisibilityActionsInput,
|
||||
OverlayUiVisibilityServiceInput,
|
||||
OverlayUiWindowState,
|
||||
OverlayUiWindowsInput,
|
||||
} from './overlay-ui-runtime';
|
||||
|
||||
type WindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
export interface OverlayUiRuntimeWindowsInput<TWindow extends WindowLike = WindowLike> {
|
||||
windowState: OverlayUiWindowState<TWindow>;
|
||||
geometry: OverlayUiGeometryInput;
|
||||
modal: OverlayUiModalInput;
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
};
|
||||
visibilityService: OverlayUiVisibilityServiceInput<TWindow>;
|
||||
overlayWindows: OverlayUiWindowsInput<TWindow>;
|
||||
visibilityActions: OverlayUiVisibilityActionsInput;
|
||||
}
|
||||
|
||||
export interface OverlayUiRuntimeGroupedInput<TWindow extends WindowLike = WindowLike> {
|
||||
windows: OverlayUiRuntimeWindowsInput<TWindow>;
|
||||
overlayActions: OverlayUiActionsInput;
|
||||
tray: OverlayUiTrayInput | null;
|
||||
bootstrap: OverlayUiBootstrapInput;
|
||||
runtimeState: OverlayUiRuntimeStateInput;
|
||||
mpvSubtitle: OverlayUiMpvSubtitleInput;
|
||||
}
|
||||
|
||||
export type OverlayUiRuntimeInputLike<TWindow extends WindowLike = WindowLike> =
|
||||
| OverlayUiRuntimeInput<TWindow>
|
||||
| OverlayUiRuntimeGroupedInput<TWindow>;
|
||||
|
||||
export function normalizeOverlayUiRuntimeInput<TWindow extends WindowLike>(
|
||||
input: OverlayUiRuntimeInputLike<TWindow>,
|
||||
): OverlayUiRuntimeInput<TWindow> {
|
||||
if (!('windows' in input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return {
|
||||
windowState: input.windows.windowState,
|
||||
geometry: input.windows.geometry,
|
||||
modal: input.windows.modal,
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: (modal) =>
|
||||
input.windows.modalRuntime.handleOverlayModalClosed(modal),
|
||||
notifyOverlayModalOpened: (modal) =>
|
||||
input.windows.modalRuntime.notifyOverlayModalOpened(modal),
|
||||
waitForModalOpen: (modal, timeoutMs) =>
|
||||
input.windows.modalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
input.windows.modalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
||||
openRuntimeOptionsPalette: () => input.windows.modalRuntime.openRuntimeOptionsPalette(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
input.windows.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
},
|
||||
visibilityService: input.windows.visibilityService,
|
||||
overlayWindows: input.windows.overlayWindows,
|
||||
visibilityActions: input.windows.visibilityActions,
|
||||
overlayActions: input.overlayActions,
|
||||
tray: input.tray,
|
||||
bootstrap: input.bootstrap,
|
||||
runtimeState: input.runtimeState,
|
||||
mpvSubtitle: input.mpvSubtitle,
|
||||
};
|
||||
}
|
||||
461
src/main/overlay-ui-runtime.test.ts
Normal file
461
src/main/overlay-ui-runtime.test.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
|
||||
import { createOverlayUiRuntime } from './overlay-ui-runtime';
|
||||
|
||||
type MockWindow = {
|
||||
destroyed: boolean;
|
||||
isDestroyed: () => boolean;
|
||||
};
|
||||
|
||||
function createWindow(): MockWindow {
|
||||
return {
|
||||
destroyed: false,
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('overlay ui runtime lazy-creates main window for toggle visibility actions', async () => {
|
||||
const calls: string[] = [];
|
||||
let mainWindow: MockWindow | null = null;
|
||||
const createdWindow = createWindow();
|
||||
let visibleOverlayVisible = false;
|
||||
|
||||
const overlayUi = createOverlayUiRuntime({
|
||||
windows: {
|
||||
windowState: {
|
||||
getMainWindow: () => mainWindow,
|
||||
setMainWindow: (window) => {
|
||||
mainWindow = window;
|
||||
},
|
||||
getModalWindow: () => null,
|
||||
setModalWindow: () => {},
|
||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlayVisible = visible;
|
||||
},
|
||||
getOverlayDebugVisualizationEnabled: () => false,
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
},
|
||||
geometry: {
|
||||
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
modal: {
|
||||
onModalStateChange: () => {},
|
||||
},
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: () => {},
|
||||
notifyOverlayModalOpened: () => {},
|
||||
waitForModalOpen: async () => false,
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
sendToActiveOverlayWindow: () => false,
|
||||
},
|
||||
visibilityService: {
|
||||
getModalActive: () => false,
|
||||
getForceMousePassthrough: () => false,
|
||||
getWindowTracker: () => null,
|
||||
getTrackerNotReadyWarningShown: () => false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
syncPrimaryOverlayWindowLayer: () => {},
|
||||
enforceOverlayLayerOrder: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: () => false,
|
||||
isWindowsPlatform: () => false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
overlayWindows: {
|
||||
createOverlayWindowCore: () => createdWindow,
|
||||
isDev: false,
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
onRuntimeOptionsChanged: () => {},
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
isOverlayVisible: () => visibleOverlayVisible,
|
||||
getYomitanSession: () => null,
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => {},
|
||||
onWindowClosed: () => {},
|
||||
},
|
||||
visibilityActions: {
|
||||
setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => {
|
||||
calls.push(`setVisible:${visible}`);
|
||||
setVisibleOverlayVisibleState(visible);
|
||||
},
|
||||
},
|
||||
},
|
||||
overlayActions: {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getMpvClient: () => null,
|
||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
||||
},
|
||||
tray: null,
|
||||
bootstrap: {
|
||||
initializeOverlayRuntimeMainDeps: {
|
||||
appState: {
|
||||
backendOverride: null,
|
||||
windowTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
mpvClient: null,
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
runtimeOptionsManager: null,
|
||||
ankiIntegration: null,
|
||||
},
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => {},
|
||||
},
|
||||
createMainWindow: () => {
|
||||
calls.push('bootstrapCreateMainWindow');
|
||||
},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
initializeOverlayRuntimeCore: () => {},
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
},
|
||||
onInitialized: () => {},
|
||||
},
|
||||
runtimeState: {
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
},
|
||||
mpvSubtitle: {
|
||||
ensureOverlayMpvSubtitlesHidden: async () => {
|
||||
calls.push('hideMpvSubs');
|
||||
},
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
calls.push('syncMpvSubs');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
overlayUi.toggleVisibleOverlay();
|
||||
|
||||
assert.equal(mainWindow, createdWindow);
|
||||
assert.deepEqual(calls, ['hideMpvSubs', 'setVisible:true', 'syncMpvSubs']);
|
||||
});
|
||||
|
||||
test('overlay ui runtime initializes overlay runtime before visible action when needed', async () => {
|
||||
const calls: string[] = [];
|
||||
let visibleOverlayVisible = false;
|
||||
let overlayRuntimeInitialized = false;
|
||||
|
||||
const overlayUi = createOverlayUiRuntime({
|
||||
windowState: {
|
||||
getMainWindow: () => null,
|
||||
setMainWindow: () => {},
|
||||
getModalWindow: () => null,
|
||||
setModalWindow: () => {},
|
||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
visibleOverlayVisible = visible;
|
||||
},
|
||||
getOverlayDebugVisualizationEnabled: () => false,
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
},
|
||||
geometry: {
|
||||
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
modal: {
|
||||
onModalStateChange: () => {},
|
||||
},
|
||||
modalRuntime: {
|
||||
handleOverlayModalClosed: () => {},
|
||||
notifyOverlayModalOpened: () => {},
|
||||
waitForModalOpen: async () => false,
|
||||
getRestoreVisibleOverlayOnModalClose: () => new Set<OverlayHostedModal>(),
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
sendToActiveOverlayWindow: () => false,
|
||||
},
|
||||
visibilityService: {
|
||||
getModalActive: () => false,
|
||||
getForceMousePassthrough: () => false,
|
||||
getWindowTracker: () => null,
|
||||
getTrackerNotReadyWarningShown: () => false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
syncPrimaryOverlayWindowLayer: () => {},
|
||||
enforceOverlayLayerOrder: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: () => false,
|
||||
isWindowsPlatform: () => false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
overlayWindows: {
|
||||
createOverlayWindowCore: () => createWindow(),
|
||||
isDev: false,
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
onRuntimeOptionsChanged: () => {},
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
isOverlayVisible: () => visibleOverlayVisible,
|
||||
getYomitanSession: () => null,
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => {},
|
||||
onWindowClosed: () => {},
|
||||
},
|
||||
visibilityActions: {
|
||||
setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => {
|
||||
calls.push(`setVisible:${visible}`);
|
||||
setVisibleOverlayVisibleState(visible);
|
||||
},
|
||||
},
|
||||
overlayActions: {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getMpvClient: () => null,
|
||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
||||
},
|
||||
tray: null,
|
||||
bootstrap: {
|
||||
initializeOverlayRuntimeMainDeps: {
|
||||
appState: {
|
||||
backendOverride: null,
|
||||
windowTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
mpvClient: null,
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
runtimeOptionsManager: null,
|
||||
ankiIntegration: null,
|
||||
},
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => visibleOverlayVisible,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => {},
|
||||
},
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
|
||||
initializeOverlayRuntimeCore: () => {
|
||||
calls.push('initializeOverlayRuntimeCore');
|
||||
},
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
overlayRuntimeInitialized = initialized;
|
||||
calls.push(`setInitialized:${initialized}`);
|
||||
},
|
||||
startBackgroundWarmups: () => {
|
||||
calls.push('startBackgroundWarmups');
|
||||
},
|
||||
},
|
||||
onInitialized: () => {
|
||||
calls.push('onInitialized');
|
||||
},
|
||||
},
|
||||
runtimeState: {
|
||||
isOverlayRuntimeInitialized: () => overlayRuntimeInitialized,
|
||||
setOverlayRuntimeInitialized: (initialized) => {
|
||||
overlayRuntimeInitialized = initialized;
|
||||
},
|
||||
},
|
||||
mpvSubtitle: {
|
||||
ensureOverlayMpvSubtitlesHidden: async () => {
|
||||
calls.push('hideMpvSubs');
|
||||
},
|
||||
syncOverlayMpvSubtitleSuppression: () => {
|
||||
calls.push('syncMpvSubs');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
overlayUi.setVisibleOverlayVisible(true);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'setInitialized:true',
|
||||
'initializeOverlayRuntimeCore',
|
||||
'startBackgroundWarmups',
|
||||
'onInitialized',
|
||||
'syncMpvSubs',
|
||||
'hideMpvSubs',
|
||||
'setVisible:true',
|
||||
'syncMpvSubs',
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlay ui runtime delegates modal actions to injected modal runtime', async () => {
|
||||
const calls: string[] = [];
|
||||
const restoreOnClose = new Set<OverlayHostedModal>();
|
||||
|
||||
const overlayUi = createOverlayUiRuntime({
|
||||
windowState: {
|
||||
getMainWindow: () => null,
|
||||
setMainWindow: () => {},
|
||||
getModalWindow: () => null,
|
||||
setModalWindow: () => {},
|
||||
getVisibleOverlayVisible: () => false,
|
||||
setVisibleOverlayVisible: () => {},
|
||||
getOverlayDebugVisualizationEnabled: () => false,
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
},
|
||||
geometry: {
|
||||
getCurrentOverlayGeometry: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
modal: {
|
||||
onModalStateChange: () => {},
|
||||
},
|
||||
visibilityService: {
|
||||
getModalActive: () => false,
|
||||
getForceMousePassthrough: () => false,
|
||||
getWindowTracker: () => null,
|
||||
getTrackerNotReadyWarningShown: () => false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
syncPrimaryOverlayWindowLayer: () => {},
|
||||
enforceOverlayLayerOrder: () => {},
|
||||
syncOverlayShortcuts: () => {},
|
||||
isMacOSPlatform: () => false,
|
||||
isWindowsPlatform: () => false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
||||
},
|
||||
overlayWindows: {
|
||||
createOverlayWindowCore: () => createWindow(),
|
||||
isDev: false,
|
||||
ensureOverlayWindowLevel: () => {},
|
||||
onRuntimeOptionsChanged: () => {},
|
||||
setOverlayDebugVisualizationEnabled: () => {},
|
||||
isOverlayVisible: () => false,
|
||||
getYomitanSession: () => null,
|
||||
tryHandleOverlayShortcutLocalFallback: () => false,
|
||||
forwardTabToMpv: () => {},
|
||||
onWindowClosed: () => {},
|
||||
},
|
||||
visibilityActions: {
|
||||
setVisibleOverlayVisibleCore: ({ visible, setVisibleOverlayVisibleState }) => {
|
||||
setVisibleOverlayVisibleState(visible);
|
||||
},
|
||||
},
|
||||
overlayActions: {
|
||||
getRuntimeOptionsManager: () => null,
|
||||
getMpvClient: () => null,
|
||||
broadcastRuntimeOptionsChangedRuntime: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
setOverlayDebugVisualizationEnabledRuntime: () => {},
|
||||
},
|
||||
modalRuntime: {
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
|
||||
calls.push(`send:${channel}:${String(payload)}`);
|
||||
if (runtimeOptions?.restoreOnModalClose) {
|
||||
restoreOnClose.add(runtimeOptions.restoreOnModalClose);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
openRuntimeOptionsPalette: () => {
|
||||
calls.push('openRuntimeOptionsPalette');
|
||||
},
|
||||
handleOverlayModalClosed: (modal) => {
|
||||
calls.push(`closed:${modal}`);
|
||||
},
|
||||
notifyOverlayModalOpened: (modal) => {
|
||||
calls.push(`opened:${modal}`);
|
||||
},
|
||||
waitForModalOpen: async (modal, timeoutMs) => {
|
||||
calls.push(`wait:${modal}:${timeoutMs}`);
|
||||
return true;
|
||||
},
|
||||
getRestoreVisibleOverlayOnModalClose: () => restoreOnClose,
|
||||
},
|
||||
tray: null,
|
||||
bootstrap: {
|
||||
initializeOverlayRuntimeMainDeps: {
|
||||
appState: {
|
||||
backendOverride: null,
|
||||
windowTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
mpvClient: null,
|
||||
mpvSocketPath: '/tmp/mpv.sock',
|
||||
runtimeOptionsManager: null,
|
||||
ankiIntegration: null,
|
||||
},
|
||||
overlayManager: {
|
||||
getVisibleOverlayVisible: () => false,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => {},
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => {},
|
||||
},
|
||||
createMainWindow: () => {},
|
||||
registerGlobalShortcuts: () => {},
|
||||
updateVisibleOverlayBounds: () => {},
|
||||
getOverlayWindows: () => [],
|
||||
getResolvedConfig: () => ({ ankiConnect: {} }) as never,
|
||||
showDesktopNotification: () => {},
|
||||
createFieldGroupingCallback: () => () => Promise.resolve({} as never),
|
||||
getKnownWordCacheStatePath: () => '/tmp/known.json',
|
||||
shouldStartAnkiIntegration: () => false,
|
||||
},
|
||||
initializeOverlayRuntimeBootstrapDeps: {
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
initializeOverlayRuntimeCore: () => {},
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
startBackgroundWarmups: () => {},
|
||||
},
|
||||
},
|
||||
runtimeState: {
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
setOverlayRuntimeInitialized: () => {},
|
||||
},
|
||||
mpvSubtitle: {
|
||||
ensureOverlayMpvSubtitlesHidden: async () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
overlayUi.sendToActiveOverlayWindow('jimaku:open', 'payload', {
|
||||
restoreOnModalClose: 'jimaku',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
overlayUi.openRuntimeOptionsPalette();
|
||||
overlayUi.notifyOverlayModalOpened('runtime-options');
|
||||
overlayUi.handleOverlayModalClosed('runtime-options');
|
||||
assert.equal(await overlayUi.waitForModalOpen('youtube-track-picker', 50), true);
|
||||
assert.equal(overlayUi.getRestoreVisibleOverlayOnModalClose(), restoreOnClose);
|
||||
assert.deepEqual(calls, [
|
||||
'send:jimaku:open:payload',
|
||||
'openRuntimeOptionsPalette',
|
||||
'opened:runtime-options',
|
||||
'closed:runtime-options',
|
||||
'wait:youtube-track-picker:50',
|
||||
]);
|
||||
});
|
||||
408
src/main/overlay-ui-runtime.ts
Normal file
408
src/main/overlay-ui-runtime.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import type { Session } from 'electron';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { RuntimeOptionState, WindowGeometry } from '../types';
|
||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
||||
import {
|
||||
normalizeOverlayUiRuntimeInput,
|
||||
type OverlayUiRuntimeInputLike,
|
||||
} from './overlay-ui-runtime-input';
|
||||
import {
|
||||
createOverlayVisibilityRuntimeBridge,
|
||||
type OverlayUiVisibilityBridgeWindowLike,
|
||||
} from './overlay-ui-visibility';
|
||||
import { createOverlayVisibilityRuntime } from './runtime/overlay-visibility-runtime';
|
||||
import { createOverlayWindowRuntimeHandlers } from './runtime/overlay-window-runtime-handlers';
|
||||
import { createTrayRuntimeHandlers } from './runtime/tray-runtime-handlers';
|
||||
import { createOverlayRuntimeBootstrapHandlers } from './runtime/overlay-runtime-bootstrap-handlers';
|
||||
import { composeOverlayVisibilityRuntime } from './runtime/composers/overlay-visibility-runtime-composer';
|
||||
import {
|
||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler,
|
||||
createBuildGetRuntimeOptionsStateMainDepsHandler,
|
||||
createBuildOpenRuntimeOptionsPaletteMainDepsHandler,
|
||||
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler,
|
||||
createBuildSendToActiveOverlayWindowMainDepsHandler,
|
||||
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler,
|
||||
} from './runtime/overlay-runtime-main-actions-main-deps';
|
||||
import { createGetRuntimeOptionsStateHandler } from './runtime/overlay-runtime-main-actions';
|
||||
|
||||
type OverlayWindowKind = 'visible' | 'modal';
|
||||
|
||||
type WindowLike = OverlayUiVisibilityBridgeWindowLike;
|
||||
|
||||
type RuntimeOptionsManagerLike = {
|
||||
listOptions: () => RuntimeOptionState[];
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
};
|
||||
|
||||
type TrayHandlersDeps = Parameters<typeof createTrayRuntimeHandlers>[0];
|
||||
type BootstrapHandlersDeps = Parameters<typeof createOverlayRuntimeBootstrapHandlers>[0];
|
||||
|
||||
type OverlayWindowCreateOptions<TWindow extends WindowLike> = {
|
||||
isDev: boolean;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
onRuntimeOptionsChanged: () => void;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
yomitanSession?: Electron.Session | null;
|
||||
};
|
||||
|
||||
export interface OverlayUiWindowState<TWindow extends WindowLike = WindowLike> {
|
||||
getMainWindow: () => TWindow | null;
|
||||
setMainWindow: (window: TWindow | null) => void;
|
||||
getModalWindow: () => TWindow | null;
|
||||
setModalWindow: (window: TWindow | null) => void;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getOverlayDebugVisualizationEnabled: () => boolean;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiGeometryInput {
|
||||
getCurrentOverlayGeometry: () => WindowGeometry;
|
||||
}
|
||||
|
||||
export interface OverlayUiModalInput {
|
||||
setModalWindowBounds?: (geometry: WindowGeometry) => void;
|
||||
onModalStateChange?: (active: boolean) => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiVisibilityServiceInput<TWindow extends WindowLike = WindowLike> {
|
||||
getModalActive: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getWindowTracker: () => unknown;
|
||||
getTrackerNotReadyWarningShown: () => boolean;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
isMacOSPlatform: () => boolean;
|
||||
isWindowsPlatform: () => boolean;
|
||||
showOverlayLoadingOsd: (message: string) => void;
|
||||
resolveFallbackBounds: () => WindowGeometry;
|
||||
}
|
||||
|
||||
export interface OverlayUiWindowsInput<TWindow extends WindowLike = WindowLike> {
|
||||
createOverlayWindowCore: (
|
||||
kind: OverlayWindowKind,
|
||||
options: OverlayWindowCreateOptions<TWindow>,
|
||||
) => TWindow;
|
||||
isDev: boolean;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
onRuntimeOptionsChanged: () => void;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
|
||||
getYomitanSession: () => Session | null;
|
||||
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
|
||||
forwardTabToMpv: () => void;
|
||||
onWindowClosed: (windowKind: OverlayWindowKind) => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiVisibilityActionsInput {
|
||||
setVisibleOverlayVisibleCore: (options: {
|
||||
visible: boolean;
|
||||
setVisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiActionsInput {
|
||||
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
|
||||
broadcastRuntimeOptionsChangedRuntime: (
|
||||
getRuntimeOptionsState: () => RuntimeOptionState[],
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void,
|
||||
) => void;
|
||||
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
||||
setOverlayDebugVisualizationEnabledRuntime: (
|
||||
currentEnabled: boolean,
|
||||
nextEnabled: boolean,
|
||||
setCurrentEnabled: (enabled: boolean) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiTrayInput {
|
||||
resolveTrayIconPathDeps: TrayHandlersDeps['resolveTrayIconPathDeps'];
|
||||
buildTrayMenuTemplateDeps: TrayHandlersDeps['buildTrayMenuTemplateDeps'];
|
||||
ensureTrayDeps: TrayHandlersDeps['ensureTrayDeps'];
|
||||
destroyTrayDeps: TrayHandlersDeps['destroyTrayDeps'];
|
||||
buildMenuFromTemplate: TrayHandlersDeps['buildMenuFromTemplate'];
|
||||
}
|
||||
|
||||
export interface OverlayUiBootstrapInput {
|
||||
initializeOverlayRuntimeMainDeps: BootstrapHandlersDeps['initializeOverlayRuntimeMainDeps'];
|
||||
initializeOverlayRuntimeBootstrapDeps: BootstrapHandlersDeps['initializeOverlayRuntimeBootstrapDeps'];
|
||||
onInitialized?: () => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiRuntimeStateInput {
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
setOverlayRuntimeInitialized: (initialized: boolean) => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiMpvSubtitleInput {
|
||||
ensureOverlayMpvSubtitlesHidden: () => Promise<void> | void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
}
|
||||
|
||||
export interface OverlayUiRuntimeInput<TWindow extends WindowLike = WindowLike> {
|
||||
windowState: OverlayUiWindowState<TWindow>;
|
||||
geometry: OverlayUiGeometryInput;
|
||||
modal: OverlayUiModalInput;
|
||||
modalRuntime: OverlayModalRuntime;
|
||||
visibilityService: OverlayUiVisibilityServiceInput<TWindow>;
|
||||
overlayWindows: OverlayUiWindowsInput<TWindow>;
|
||||
visibilityActions: OverlayUiVisibilityActionsInput;
|
||||
overlayActions: OverlayUiActionsInput;
|
||||
tray: OverlayUiTrayInput | null;
|
||||
bootstrap: OverlayUiBootstrapInput;
|
||||
runtimeState: OverlayUiRuntimeStateInput;
|
||||
mpvSubtitle: OverlayUiMpvSubtitleInput;
|
||||
}
|
||||
|
||||
export interface OverlayUiRuntime<TWindow extends WindowLike = WindowLike> {
|
||||
createMainWindow: () => TWindow;
|
||||
createModalWindow: () => TWindow;
|
||||
ensureTray: () => void;
|
||||
destroyTray: () => void;
|
||||
initializeOverlayRuntime: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
setOverlayVisible: (visible: boolean) => void;
|
||||
handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
|
||||
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
broadcastRuntimeOptionsChanged: () => void;
|
||||
setOverlayDebugVisualizationEnabled: (enabled: boolean) => void;
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
}
|
||||
|
||||
export function createOverlayUiRuntime<TWindow extends WindowLike>(
|
||||
input: OverlayUiRuntimeInputLike<TWindow>,
|
||||
): OverlayUiRuntime<TWindow> {
|
||||
const runtimeInput = normalizeOverlayUiRuntimeInput(input);
|
||||
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeBridge({
|
||||
getMainWindow: () => runtimeInput.windowState.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => runtimeInput.windowState.getVisibleOverlayVisible(),
|
||||
getModalActive: () => runtimeInput.visibilityService.getModalActive(),
|
||||
getForceMousePassthrough: () => runtimeInput.visibilityService.getForceMousePassthrough(),
|
||||
getWindowTracker: () => runtimeInput.visibilityService.getWindowTracker(),
|
||||
getTrackerNotReadyWarningShown: () =>
|
||||
runtimeInput.visibilityService.getTrackerNotReadyWarningShown(),
|
||||
setTrackerNotReadyWarningShown: (shown) =>
|
||||
runtimeInput.visibilityService.setTrackerNotReadyWarningShown(shown),
|
||||
updateVisibleOverlayBounds: (geometry) =>
|
||||
runtimeInput.visibilityService.updateVisibleOverlayBounds(geometry),
|
||||
ensureOverlayWindowLevel: (window) =>
|
||||
runtimeInput.visibilityService.ensureOverlayWindowLevel(window),
|
||||
syncPrimaryOverlayWindowLayer: (layer) =>
|
||||
runtimeInput.visibilityService.syncPrimaryOverlayWindowLayer(layer),
|
||||
enforceOverlayLayerOrder: () => runtimeInput.visibilityService.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => runtimeInput.visibilityService.syncOverlayShortcuts(),
|
||||
isMacOSPlatform: () => runtimeInput.visibilityService.isMacOSPlatform(),
|
||||
isWindowsPlatform: () => runtimeInput.visibilityService.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message) =>
|
||||
runtimeInput.visibilityService.showOverlayLoadingOsd(message),
|
||||
});
|
||||
|
||||
const overlayWindowHandlers = createOverlayWindowRuntimeHandlers<TWindow>({
|
||||
createOverlayWindowDeps: {
|
||||
createOverlayWindowCore: (kind, options) =>
|
||||
runtimeInput.overlayWindows.createOverlayWindowCore(kind, options),
|
||||
isDev: runtimeInput.overlayWindows.isDev,
|
||||
ensureOverlayWindowLevel: (window) =>
|
||||
runtimeInput.overlayWindows.ensureOverlayWindowLevel(window),
|
||||
onRuntimeOptionsChanged: () => runtimeInput.overlayWindows.onRuntimeOptionsChanged(),
|
||||
setOverlayDebugVisualizationEnabled: (enabled) =>
|
||||
runtimeInput.overlayWindows.setOverlayDebugVisualizationEnabled(enabled),
|
||||
isOverlayVisible: (windowKind) => runtimeInput.overlayWindows.isOverlayVisible(windowKind),
|
||||
getYomitanSession: () => runtimeInput.overlayWindows.getYomitanSession(),
|
||||
tryHandleOverlayShortcutLocalFallback: (overlayInput) =>
|
||||
runtimeInput.overlayWindows.tryHandleOverlayShortcutLocalFallback(overlayInput),
|
||||
forwardTabToMpv: () => runtimeInput.overlayWindows.forwardTabToMpv(),
|
||||
onWindowClosed: (windowKind) => runtimeInput.overlayWindows.onWindowClosed(windowKind),
|
||||
},
|
||||
setMainWindow: (window) => runtimeInput.windowState.setMainWindow(window),
|
||||
setModalWindow: (window) => runtimeInput.windowState.setModalWindow(window),
|
||||
});
|
||||
|
||||
const visibilityActions = createOverlayVisibilityRuntime({
|
||||
setVisibleOverlayVisibleDeps: {
|
||||
setVisibleOverlayVisibleCore: (options) =>
|
||||
runtimeInput.visibilityActions.setVisibleOverlayVisibleCore(options),
|
||||
setVisibleOverlayVisibleState: (visible) =>
|
||||
runtimeInput.windowState.setVisibleOverlayVisible(visible),
|
||||
updateVisibleOverlayVisibility: () =>
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
},
|
||||
getVisibleOverlayVisible: () => runtimeInput.windowState.getVisibleOverlayVisible(),
|
||||
});
|
||||
|
||||
const getRuntimeOptionsState = createGetRuntimeOptionsStateHandler(
|
||||
createBuildGetRuntimeOptionsStateMainDepsHandler({
|
||||
getRuntimeOptionsManager: () => runtimeInput.overlayActions.getRuntimeOptionsManager(),
|
||||
})(),
|
||||
);
|
||||
|
||||
const overlayActions = composeOverlayVisibilityRuntime({
|
||||
overlayVisibilityRuntime,
|
||||
restorePreviousSecondarySubVisibilityMainDeps:
|
||||
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler({
|
||||
getMpvClient: () => runtimeInput.overlayActions.getMpvClient(),
|
||||
})(),
|
||||
broadcastRuntimeOptionsChangedMainDeps:
|
||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
|
||||
broadcastRuntimeOptionsChangedRuntime: (getState, broadcast) =>
|
||||
runtimeInput.overlayActions.broadcastRuntimeOptionsChangedRuntime(getState, broadcast),
|
||||
getRuntimeOptionsState: () => getRuntimeOptionsState(),
|
||||
broadcastToOverlayWindows: (channel, ...args) =>
|
||||
runtimeInput.overlayActions.broadcastToOverlayWindows(channel, ...args),
|
||||
})(),
|
||||
sendToActiveOverlayWindowMainDeps: createBuildSendToActiveOverlayWindowMainDepsHandler({
|
||||
sendToActiveOverlayWindowRuntime: (channel, payload, runtimeOptions) =>
|
||||
runtimeInput.modalRuntime.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
})(),
|
||||
setOverlayDebugVisualizationEnabledMainDeps:
|
||||
createBuildSetOverlayDebugVisualizationEnabledMainDepsHandler({
|
||||
setOverlayDebugVisualizationEnabledRuntime: (currentEnabled, nextEnabled, setCurrent) =>
|
||||
runtimeInput.overlayActions.setOverlayDebugVisualizationEnabledRuntime(
|
||||
currentEnabled,
|
||||
nextEnabled,
|
||||
setCurrent,
|
||||
),
|
||||
getCurrentEnabled: () => runtimeInput.windowState.getOverlayDebugVisualizationEnabled(),
|
||||
setCurrentEnabled: (enabled) =>
|
||||
runtimeInput.windowState.setOverlayDebugVisualizationEnabled(enabled),
|
||||
})(),
|
||||
openRuntimeOptionsPaletteMainDeps: createBuildOpenRuntimeOptionsPaletteMainDepsHandler({
|
||||
openRuntimeOptionsPaletteRuntime: () => runtimeInput.modalRuntime.openRuntimeOptionsPalette(),
|
||||
})(),
|
||||
});
|
||||
|
||||
const trayHandlers = runtimeInput.tray
|
||||
? createTrayRuntimeHandlers({
|
||||
resolveTrayIconPathDeps: runtimeInput.tray.resolveTrayIconPathDeps,
|
||||
buildTrayMenuTemplateDeps: runtimeInput.tray.buildTrayMenuTemplateDeps,
|
||||
ensureTrayDeps: runtimeInput.tray.ensureTrayDeps,
|
||||
destroyTrayDeps: runtimeInput.tray.destroyTrayDeps,
|
||||
buildMenuFromTemplate: (template) => runtimeInput.tray!.buildMenuFromTemplate(template),
|
||||
})
|
||||
: null;
|
||||
|
||||
const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
createOverlayRuntimeBootstrapHandlers({
|
||||
initializeOverlayRuntimeMainDeps: runtimeInput.bootstrap.initializeOverlayRuntimeMainDeps,
|
||||
initializeOverlayRuntimeBootstrapDeps:
|
||||
runtimeInput.bootstrap.initializeOverlayRuntimeBootstrapDeps,
|
||||
});
|
||||
|
||||
function createMainWindow(): TWindow {
|
||||
return overlayWindowHandlers.createMainWindow();
|
||||
}
|
||||
|
||||
function createModalWindow(): TWindow {
|
||||
const existingWindow = runtimeInput.windowState.getModalWindow();
|
||||
if (existingWindow && !existingWindow.isDestroyed()) {
|
||||
return existingWindow;
|
||||
}
|
||||
const window = overlayWindowHandlers.createModalWindow();
|
||||
runtimeInput.modal.setModalWindowBounds?.(runtimeInput.geometry.getCurrentOverlayGeometry());
|
||||
return window;
|
||||
}
|
||||
|
||||
function initializeOverlayRuntime(): void {
|
||||
initializeOverlayRuntimeHandler();
|
||||
runtimeInput.bootstrap.onInitialized?.();
|
||||
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function ensureOverlayWindowsReadyForVisibilityActions(): void {
|
||||
if (!runtimeInput.runtimeState.isOverlayRuntimeInitialized()) {
|
||||
initializeOverlayRuntime();
|
||||
return;
|
||||
}
|
||||
|
||||
const mainWindow = runtimeInput.windowState.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
createMainWindow();
|
||||
}
|
||||
}
|
||||
|
||||
function setVisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (visible) {
|
||||
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
visibilityActions.setVisibleOverlayVisible(visible);
|
||||
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!runtimeInput.windowState.getVisibleOverlayVisible()) {
|
||||
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
visibilityActions.toggleVisibleOverlay();
|
||||
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
if (visible) {
|
||||
void runtimeInput.mpvSubtitle.ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
visibilityActions.setOverlayVisible(visible);
|
||||
runtimeInput.mpvSubtitle.syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
return {
|
||||
createMainWindow,
|
||||
createModalWindow,
|
||||
ensureTray: () => {
|
||||
trayHandlers?.ensureTray();
|
||||
},
|
||||
destroyTray: () => {
|
||||
trayHandlers?.destroyTray();
|
||||
},
|
||||
initializeOverlayRuntime,
|
||||
ensureOverlayWindowsReadyForVisibilityActions,
|
||||
setVisibleOverlayVisible,
|
||||
toggleVisibleOverlay,
|
||||
setOverlayVisible,
|
||||
handleOverlayModalClosed: (modal) => runtimeInput.modalRuntime.handleOverlayModalClosed(modal),
|
||||
notifyOverlayModalOpened: (modal) => runtimeInput.modalRuntime.notifyOverlayModalOpened(modal),
|
||||
waitForModalOpen: (modal, timeoutMs) =>
|
||||
runtimeInput.modalRuntime.waitForModalOpen(modal, timeoutMs),
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
runtimeInput.modalRuntime.getRestoreVisibleOverlayOnModalClose(),
|
||||
updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
overlayActions.sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
openRuntimeOptionsPalette: () => overlayActions.openRuntimeOptionsPalette(),
|
||||
broadcastRuntimeOptionsChanged: () => overlayActions.broadcastRuntimeOptionsChanged(),
|
||||
setOverlayDebugVisualizationEnabled: (enabled) =>
|
||||
overlayActions.setOverlayDebugVisualizationEnabled(enabled),
|
||||
restorePreviousSecondarySubVisibility: () =>
|
||||
overlayActions.restorePreviousSecondarySubVisibility(),
|
||||
};
|
||||
}
|
||||
128
src/main/overlay-ui-visibility.ts
Normal file
128
src/main/overlay-ui-visibility.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
export type OverlayUiVisibilityBridgeWindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
hide?: () => void;
|
||||
show?: () => void;
|
||||
focus?: () => void;
|
||||
setIgnoreMouseEvents?: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
};
|
||||
|
||||
export interface OverlayUiVisibilityBridgeInput<
|
||||
TWindow extends OverlayUiVisibilityBridgeWindowLike = OverlayUiVisibilityBridgeWindowLike,
|
||||
> {
|
||||
getMainWindow: () => TWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getModalActive: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getWindowTracker: () => unknown;
|
||||
getTrackerNotReadyWarningShown: () => boolean;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
ensureOverlayWindowLevel: (window: TWindow) => void;
|
||||
syncPrimaryOverlayWindowLayer: (layer: 'visible') => void;
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
isMacOSPlatform: () => boolean;
|
||||
isWindowsPlatform: () => boolean;
|
||||
showOverlayLoadingOsd: (message: string) => void;
|
||||
}
|
||||
|
||||
export function createOverlayVisibilityRuntimeBridge<
|
||||
TWindow extends OverlayUiVisibilityBridgeWindowLike,
|
||||
>(input: OverlayUiVisibilityBridgeInput<TWindow>) {
|
||||
let lastOverlayLoadingOsdAtMs: number | null = null;
|
||||
|
||||
return {
|
||||
updateVisibleOverlayVisibility(): void {
|
||||
const mainWindow = input.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.getModalActive()) {
|
||||
mainWindow.hide?.();
|
||||
input.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
const showPassiveVisibleOverlay = (): void => {
|
||||
const forceMousePassthrough = input.getForceMousePassthrough() === true;
|
||||
if (input.isWindowsPlatform() || forceMousePassthrough) {
|
||||
mainWindow.setIgnoreMouseEvents?.(true, { forward: true });
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents?.(false);
|
||||
}
|
||||
input.ensureOverlayWindowLevel(mainWindow);
|
||||
mainWindow.show?.();
|
||||
if (!input.isWindowsPlatform() && !input.isMacOSPlatform() && !forceMousePassthrough) {
|
||||
mainWindow.focus?.();
|
||||
}
|
||||
};
|
||||
|
||||
const maybeShowOverlayLoadingOsd = (): void => {
|
||||
if (!input.isMacOSPlatform()) {
|
||||
return;
|
||||
}
|
||||
if (lastOverlayLoadingOsdAtMs !== null && Date.now() - lastOverlayLoadingOsdAtMs < 30_000) {
|
||||
return;
|
||||
}
|
||||
input.showOverlayLoadingOsd('Overlay loading...');
|
||||
lastOverlayLoadingOsdAtMs = Date.now();
|
||||
};
|
||||
|
||||
if (!input.getVisibleOverlayVisible()) {
|
||||
input.setTrackerNotReadyWarningShown(false);
|
||||
lastOverlayLoadingOsdAtMs = null;
|
||||
mainWindow.hide?.();
|
||||
input.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
const windowTracker = input.getWindowTracker() as {
|
||||
isTracking: () => boolean;
|
||||
getGeometry: () => WindowGeometry | null;
|
||||
} | null;
|
||||
|
||||
if (windowTracker && windowTracker.isTracking()) {
|
||||
input.setTrackerNotReadyWarningShown(false);
|
||||
const geometry = windowTracker.getGeometry();
|
||||
if (geometry) {
|
||||
input.updateVisibleOverlayBounds(geometry);
|
||||
}
|
||||
input.syncPrimaryOverlayWindowLayer('visible');
|
||||
showPassiveVisibleOverlay();
|
||||
input.enforceOverlayLayerOrder();
|
||||
input.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!windowTracker) {
|
||||
if (input.isMacOSPlatform() || input.isWindowsPlatform()) {
|
||||
if (!input.getTrackerNotReadyWarningShown()) {
|
||||
input.setTrackerNotReadyWarningShown(true);
|
||||
maybeShowOverlayLoadingOsd();
|
||||
}
|
||||
mainWindow.hide?.();
|
||||
input.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
input.setTrackerNotReadyWarningShown(false);
|
||||
input.syncPrimaryOverlayWindowLayer('visible');
|
||||
showPassiveVisibleOverlay();
|
||||
input.enforceOverlayLayerOrder();
|
||||
input.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!input.getTrackerNotReadyWarningShown()) {
|
||||
input.setTrackerNotReadyWarningShown(true);
|
||||
maybeShowOverlayLoadingOsd();
|
||||
}
|
||||
|
||||
mainWindow.hide?.();
|
||||
input.syncOverlayShortcuts();
|
||||
},
|
||||
};
|
||||
}
|
||||
41
src/main/runtime-option-helpers.ts
Normal file
41
src/main/runtime-option-helpers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ResolvedConfig } from '../types';
|
||||
|
||||
export function getRuntimeBooleanOption(
|
||||
getOptionValue: (
|
||||
id:
|
||||
| 'subtitle.annotation.nPlusOne'
|
||||
| 'subtitle.annotation.jlpt'
|
||||
| 'subtitle.annotation.frequency',
|
||||
) => unknown,
|
||||
id: 'subtitle.annotation.nPlusOne' | 'subtitle.annotation.jlpt' | 'subtitle.annotation.frequency',
|
||||
fallback: boolean,
|
||||
): boolean {
|
||||
const value = getOptionValue(id);
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
export function shouldInitializeMecabForAnnotations(input: {
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getRuntimeBooleanOption: (
|
||||
id:
|
||||
| 'subtitle.annotation.nPlusOne'
|
||||
| 'subtitle.annotation.jlpt'
|
||||
| 'subtitle.annotation.frequency',
|
||||
fallback: boolean,
|
||||
) => boolean;
|
||||
}): boolean {
|
||||
const config = input.getResolvedConfig();
|
||||
const nPlusOneEnabled = input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.nPlusOne',
|
||||
config.ankiConnect.knownWords.highlightEnabled,
|
||||
);
|
||||
const jlptEnabled = input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.jlpt',
|
||||
config.subtitleStyle.enableJlpt,
|
||||
);
|
||||
const frequencyEnabled = input.getRuntimeBooleanOption(
|
||||
'subtitle.annotation.frequency',
|
||||
config.subtitleStyle.frequencyDictionary.enabled,
|
||||
);
|
||||
return nPlusOneEnabled || jlptEnabled || frequencyEnabled;
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { createDiscordPresenceService } from '../../core/services';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import { createDiscordRpcClient } from './discord-rpc-client.js';
|
||||
|
||||
type DiscordPresenceServiceLike = {
|
||||
publish: (snapshot: {
|
||||
mediaTitle: string | null;
|
||||
@@ -72,3 +76,59 @@ export function createDiscordPresenceRuntime(deps: DiscordPresenceRuntimeDeps) {
|
||||
publishDiscordPresence,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDiscordPresenceRuntimeFromMainState(input: {
|
||||
appId: string;
|
||||
appState: {
|
||||
discordPresenceService: ReturnType<typeof createDiscordPresenceService> | null;
|
||||
mpvClient: MpvClientLike | null;
|
||||
currentMediaTitle: string | null;
|
||||
currentMediaPath: string | null;
|
||||
currentSubText: string;
|
||||
playbackPaused: boolean | null;
|
||||
};
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getFallbackMediaDurationSec: () => number | null;
|
||||
logger: {
|
||||
debug: (message: string, meta?: unknown) => void;
|
||||
};
|
||||
}) {
|
||||
const sessionStartedAtMs = Date.now();
|
||||
let mediaDurationSec: number | null = null;
|
||||
|
||||
const discordPresenceRuntime = createDiscordPresenceRuntime({
|
||||
getDiscordPresenceService: () => input.appState.discordPresenceService,
|
||||
isDiscordPresenceEnabled: () => input.getResolvedConfig().discordPresence.enabled === true,
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
|
||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
||||
getCurrentSubtitleText: () => input.appState.currentSubText,
|
||||
getPlaybackPaused: () => input.appState.playbackPaused,
|
||||
getFallbackMediaDurationSec: () => input.getFallbackMediaDurationSec(),
|
||||
getSessionStartedAtMs: () => sessionStartedAtMs,
|
||||
getMediaDurationSec: () => mediaDurationSec,
|
||||
setMediaDurationSec: (next) => {
|
||||
mediaDurationSec = next;
|
||||
},
|
||||
});
|
||||
|
||||
const initializeDiscordPresenceService = async (): Promise<void> => {
|
||||
if (input.getResolvedConfig().discordPresence.enabled !== true) {
|
||||
input.appState.discordPresenceService = null;
|
||||
return;
|
||||
}
|
||||
|
||||
input.appState.discordPresenceService = createDiscordPresenceService({
|
||||
config: input.getResolvedConfig().discordPresence,
|
||||
createClient: () => createDiscordRpcClient(input.appId),
|
||||
logDebug: (message, meta) => input.logger.debug(message, meta),
|
||||
});
|
||||
await input.appState.discordPresenceService.start();
|
||||
discordPresenceRuntime.publishDiscordPresence();
|
||||
};
|
||||
|
||||
return {
|
||||
discordPresenceRuntime,
|
||||
initializeDiscordPresenceService,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,10 +98,62 @@ export function createRestoreOverlayMpvSubtitlesHandler(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedVisibility !== null) {
|
||||
deps.setMpvSubVisibility(savedVisibility);
|
||||
}
|
||||
|
||||
deps.setSavedSubVisibility(null);
|
||||
};
|
||||
}
|
||||
|
||||
export function createOverlayMpvSubtitleSuppressionRuntime(deps: {
|
||||
appState: {
|
||||
mpvClient: MpvVisibilityClient | null;
|
||||
overlaySavedMpvSubVisibility: boolean | null;
|
||||
overlayMpvSubVisibilityRevision: number;
|
||||
};
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
setMpvSubVisibility: (visible: boolean) => void;
|
||||
logWarn: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
const ensureOverlayMpvSubtitlesHidden = createEnsureOverlayMpvSubtitlesHiddenHandler({
|
||||
getMpvClient: () => deps.appState.mpvClient,
|
||||
getSavedSubVisibility: () => deps.appState.overlaySavedMpvSubVisibility,
|
||||
setSavedSubVisibility: (visible) => {
|
||||
deps.appState.overlaySavedMpvSubVisibility = visible;
|
||||
},
|
||||
getRevision: () => deps.appState.overlayMpvSubVisibilityRevision,
|
||||
setRevision: (revision) => {
|
||||
deps.appState.overlayMpvSubVisibilityRevision = revision;
|
||||
},
|
||||
setMpvSubVisibility: (visible) => deps.setMpvSubVisibility(visible),
|
||||
logWarn: (message, error) => deps.logWarn(message, error),
|
||||
});
|
||||
|
||||
const restoreOverlayMpvSubtitles = createRestoreOverlayMpvSubtitlesHandler({
|
||||
getSavedSubVisibility: () => deps.appState.overlaySavedMpvSubVisibility,
|
||||
setSavedSubVisibility: (visible) => {
|
||||
deps.appState.overlaySavedMpvSubVisibility = visible;
|
||||
},
|
||||
getRevision: () => deps.appState.overlayMpvSubVisibilityRevision,
|
||||
setRevision: (revision) => {
|
||||
deps.appState.overlayMpvSubVisibilityRevision = revision;
|
||||
},
|
||||
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
|
||||
shouldKeepSuppressedFromVisibleOverlayBinding: () => deps.getVisibleOverlayVisible(),
|
||||
setMpvSubVisibility: (visible) => deps.setMpvSubVisibility(visible),
|
||||
});
|
||||
|
||||
const syncOverlayMpvSubtitleSuppression = (): void => {
|
||||
if (deps.getVisibleOverlayVisible()) {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
return;
|
||||
}
|
||||
|
||||
restoreOverlayMpvSubtitles();
|
||||
};
|
||||
|
||||
return {
|
||||
ensureOverlayMpvSubtitlesHidden,
|
||||
restoreOverlayMpvSubtitles,
|
||||
syncOverlayMpvSubtitleSuppression,
|
||||
};
|
||||
}
|
||||
|
||||
77
src/main/shortcuts-runtime.test.ts
Normal file
77
src/main/shortcuts-runtime.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createShortcutsRuntime } from './shortcuts-runtime';
|
||||
|
||||
test('shortcuts runtime bridges modal shortcut sync to unregister and sync', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createShortcutsRuntime({
|
||||
globalShortcuts: {
|
||||
getConfiguredShortcutsMainDeps: {
|
||||
getResolvedConfig: () => ({}) as never,
|
||||
defaultConfig: {} as never,
|
||||
resolveConfiguredShortcuts: () => ({}) as never,
|
||||
},
|
||||
buildRegisterGlobalShortcutsMainDeps: () => ({
|
||||
getConfiguredShortcuts: () => ({}) as never,
|
||||
registerGlobalShortcutsCore: () => {
|
||||
calls.push('registerGlobalShortcutsCore');
|
||||
},
|
||||
toggleVisibleOverlay: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
isDev: false,
|
||||
getMainWindow: () => null,
|
||||
}),
|
||||
buildRefreshGlobalAndOverlayShortcutsMainDeps: () => ({
|
||||
unregisterAllGlobalShortcuts: () => {
|
||||
calls.push('unregisterAllGlobalShortcuts');
|
||||
},
|
||||
registerGlobalShortcuts: () => {
|
||||
calls.push('registerGlobalShortcuts');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('syncOverlayShortcuts');
|
||||
},
|
||||
}),
|
||||
},
|
||||
numericShortcutRuntimeMainDeps: {
|
||||
globalShortcut: {
|
||||
register: () => true,
|
||||
unregister: () => {},
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
|
||||
clearTimer: (timer) => clearTimeout(timer),
|
||||
},
|
||||
numericSessions: {
|
||||
onMultiCopyDigit: () => {},
|
||||
onMineSentenceDigit: () => {},
|
||||
},
|
||||
overlayShortcutsRuntimeMainDeps: {
|
||||
overlayShortcutsRuntime: {
|
||||
registerOverlayShortcuts: () => {
|
||||
calls.push('registerOverlayShortcuts');
|
||||
},
|
||||
unregisterOverlayShortcuts: () => {
|
||||
calls.push('unregisterOverlayShortcuts');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('syncOverlayShortcutsRuntime');
|
||||
},
|
||||
refreshOverlayShortcuts: () => {
|
||||
calls.push('refreshOverlayShortcuts');
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(typeof runtime.getConfiguredShortcuts, 'function');
|
||||
assert.equal(typeof runtime.registerGlobalShortcuts, 'function');
|
||||
assert.equal(typeof runtime.syncOverlayShortcutsForModal, 'function');
|
||||
|
||||
runtime.syncOverlayShortcutsForModal(true);
|
||||
runtime.syncOverlayShortcutsForModal(false);
|
||||
|
||||
assert.deepEqual(calls, ['unregisterOverlayShortcuts', 'syncOverlayShortcutsRuntime']);
|
||||
});
|
||||
278
src/main/shortcuts-runtime.ts
Normal file
278
src/main/shortcuts-runtime.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import type { ConfiguredShortcuts } from '../core/utils/shortcut-config';
|
||||
import { DEFAULT_CONFIG } from '../config';
|
||||
import { resolveConfiguredShortcuts } from '../core/utils';
|
||||
import type { AppState } from './state';
|
||||
import type { MiningRuntime } from './mining-runtime';
|
||||
import type { OverlayModalRuntime } from './overlay-runtime';
|
||||
import { createBuildOverlayShortcutsRuntimeMainDepsHandler } from './runtime/domains/shortcuts';
|
||||
import {
|
||||
composeShortcutRuntimes,
|
||||
type ShortcutsRuntimeComposerOptions,
|
||||
} from './runtime/composers/shortcuts-runtime-composer';
|
||||
import { createOverlayShortcutsRuntimeService } from './overlay-shortcuts-runtime';
|
||||
|
||||
type GlobalShortcutsInput = ShortcutsRuntimeComposerOptions['globalShortcuts'];
|
||||
type NumericShortcutRuntimeMainDepsInput =
|
||||
ShortcutsRuntimeComposerOptions['numericShortcutRuntimeMainDeps'];
|
||||
type NumericSessionsInput = ShortcutsRuntimeComposerOptions['numericSessions'];
|
||||
type OverlayShortcutsRuntimeMainDepsInput =
|
||||
ShortcutsRuntimeComposerOptions['overlayShortcutsRuntimeMainDeps'];
|
||||
|
||||
export interface ShortcutsRuntimeInput {
|
||||
globalShortcuts: GlobalShortcutsInput;
|
||||
numericShortcutRuntimeMainDeps: NumericShortcutRuntimeMainDepsInput;
|
||||
numericSessions: NumericSessionsInput;
|
||||
overlayShortcutsRuntimeMainDeps: OverlayShortcutsRuntimeMainDepsInput;
|
||||
}
|
||||
|
||||
export interface ShortcutsRuntime {
|
||||
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
||||
registerGlobalShortcuts: () => void;
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
cancelPendingMultiCopy: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
cancelPendingMineSentenceMultiple: () => void;
|
||||
startPendingMineSentenceMultiple: (timeoutMs: number) => void;
|
||||
registerOverlayShortcuts: () => void;
|
||||
unregisterOverlayShortcuts: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
refreshOverlayShortcuts: () => void;
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ShortcutsRuntimeBootstrapInput {
|
||||
globalShortcuts: ShortcutsRuntimeInput['globalShortcuts'];
|
||||
numericShortcutRuntimeMainDeps: ShortcutsRuntimeInput['numericShortcutRuntimeMainDeps'];
|
||||
numericSessions: ShortcutsRuntimeInput['numericSessions'];
|
||||
overlayShortcuts: {
|
||||
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
||||
getShortcutsRegistered: () => boolean;
|
||||
setShortcutsRegistered: (registered: boolean) => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
isOverlayShortcutContextActive: () => boolean;
|
||||
showMpvOsd: (text: string) => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJimaku: () => void;
|
||||
markAudioCard: () => void | Promise<void>;
|
||||
copySubtitle: () => void | Promise<void>;
|
||||
toggleSecondarySubMode: () => void;
|
||||
updateLastCardFromClipboard: () => void | Promise<void>;
|
||||
triggerFieldGrouping: () => void | Promise<void>;
|
||||
triggerSubsyncFromConfig: () => void | Promise<void>;
|
||||
mineSentenceCard: () => void | Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export function createShortcutsRuntime(input: ShortcutsRuntimeInput): ShortcutsRuntime {
|
||||
const shortcutsRuntime = composeShortcutRuntimes({
|
||||
globalShortcuts: input.globalShortcuts,
|
||||
numericShortcutRuntimeMainDeps: input.numericShortcutRuntimeMainDeps,
|
||||
numericSessions: input.numericSessions,
|
||||
overlayShortcutsRuntimeMainDeps: input.overlayShortcutsRuntimeMainDeps,
|
||||
});
|
||||
|
||||
return {
|
||||
...shortcutsRuntime,
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => {
|
||||
if (isActive) {
|
||||
shortcutsRuntime.unregisterOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
shortcutsRuntime.syncOverlayShortcuts();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface ShortcutsRuntimeBootstrap {
|
||||
shortcuts: ShortcutsRuntime;
|
||||
overlayShortcutsRuntime: ReturnType<typeof createOverlayShortcutsRuntimeService>;
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ShortcutsRuntimeFromMainStateInput {
|
||||
appState: Pick<AppState, 'overlayRuntimeInitialized' | 'shortcutsRegistered' | 'windowTracker'>;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
globalShortcut: NumericShortcutRuntimeMainDepsInput['globalShortcut'] & {
|
||||
unregisterAll: () => void;
|
||||
};
|
||||
registerGlobalShortcutsCore: typeof import('../core/services').registerGlobalShortcuts;
|
||||
isDev: boolean;
|
||||
overlay: {
|
||||
getOverlayUi: () =>
|
||||
| {
|
||||
toggleVisibleOverlay: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
overlayManager: {
|
||||
getMainWindow: () => Electron.BrowserWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
};
|
||||
overlayModalRuntime: Pick<OverlayModalRuntime, 'sendToActiveOverlayWindow'>;
|
||||
};
|
||||
actions: {
|
||||
showMpvOsd: (text: string) => void;
|
||||
openYomitanSettings: () => boolean;
|
||||
triggerSubsyncFromConfig: () => Promise<void>;
|
||||
handleCycleSecondarySubMode: () => void;
|
||||
handleMultiCopyDigit: (count: number) => void;
|
||||
};
|
||||
mining: {
|
||||
copyCurrentSubtitle: () => void;
|
||||
handleMineSentenceDigit: (count: number) => void;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
mineSentenceCard: () => Promise<void>;
|
||||
triggerFieldGrouping: () => Promise<void>;
|
||||
updateLastCardFromClipboard: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export function createShortcutsRuntimeBootstrap(
|
||||
input: ShortcutsRuntimeBootstrapInput,
|
||||
): ShortcutsRuntimeBootstrap {
|
||||
let shortcuts: ShortcutsRuntime;
|
||||
|
||||
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
||||
createBuildOverlayShortcutsRuntimeMainDepsHandler({
|
||||
getConfiguredShortcuts: () => input.overlayShortcuts.getConfiguredShortcuts(),
|
||||
getShortcutsRegistered: () => input.overlayShortcuts.getShortcutsRegistered(),
|
||||
setShortcutsRegistered: (registered: boolean) => {
|
||||
input.overlayShortcuts.setShortcutsRegistered(registered);
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => input.overlayShortcuts.isOverlayRuntimeInitialized(),
|
||||
isOverlayShortcutContextActive: () => input.overlayShortcuts.isOverlayShortcutContextActive(),
|
||||
showMpvOsd: (text: string) => input.overlayShortcuts.showMpvOsd(text),
|
||||
openRuntimeOptionsPalette: () => {
|
||||
input.overlayShortcuts.openRuntimeOptionsPalette();
|
||||
},
|
||||
openJimaku: () => {
|
||||
input.overlayShortcuts.openJimaku();
|
||||
},
|
||||
markAudioCard: () => Promise.resolve(input.overlayShortcuts.markAudioCard()),
|
||||
copySubtitleMultiple: (timeoutMs: number) => {
|
||||
shortcuts.startPendingMultiCopy(timeoutMs);
|
||||
},
|
||||
copySubtitle: () => Promise.resolve(input.overlayShortcuts.copySubtitle()),
|
||||
toggleSecondarySubMode: () => input.overlayShortcuts.toggleSecondarySubMode(),
|
||||
updateLastCardFromClipboard: () =>
|
||||
Promise.resolve(input.overlayShortcuts.updateLastCardFromClipboard()),
|
||||
triggerFieldGrouping: () => Promise.resolve(input.overlayShortcuts.triggerFieldGrouping()),
|
||||
triggerSubsyncFromConfig: () =>
|
||||
Promise.resolve(input.overlayShortcuts.triggerSubsyncFromConfig()),
|
||||
mineSentenceCard: () => Promise.resolve(input.overlayShortcuts.mineSentenceCard()),
|
||||
mineSentenceMultiple: (timeoutMs: number) => {
|
||||
shortcuts.startPendingMineSentenceMultiple(timeoutMs);
|
||||
},
|
||||
cancelPendingMultiCopy: () => {
|
||||
shortcuts.cancelPendingMultiCopy();
|
||||
},
|
||||
cancelPendingMineSentenceMultiple: () => {
|
||||
shortcuts.cancelPendingMineSentenceMultiple();
|
||||
},
|
||||
})(),
|
||||
);
|
||||
|
||||
shortcuts = createShortcutsRuntime({
|
||||
globalShortcuts: input.globalShortcuts,
|
||||
numericShortcutRuntimeMainDeps: input.numericShortcutRuntimeMainDeps,
|
||||
numericSessions: input.numericSessions,
|
||||
overlayShortcutsRuntimeMainDeps: {
|
||||
overlayShortcutsRuntime,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
shortcuts,
|
||||
overlayShortcutsRuntime,
|
||||
syncOverlayShortcutsForModal: (isActive: boolean) => {
|
||||
shortcuts.syncOverlayShortcutsForModal(isActive);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createShortcutsRuntimeFromMainState(
|
||||
input: ShortcutsRuntimeFromMainStateInput,
|
||||
): ShortcutsRuntimeBootstrap {
|
||||
let shortcuts: ShortcutsRuntime;
|
||||
|
||||
const bootstrap = createShortcutsRuntimeBootstrap({
|
||||
globalShortcuts: {
|
||||
getConfiguredShortcutsMainDeps: {
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
defaultConfig: DEFAULT_CONFIG,
|
||||
resolveConfiguredShortcuts,
|
||||
},
|
||||
buildRegisterGlobalShortcutsMainDeps: (getConfiguredShortcutsHandler) => ({
|
||||
getConfiguredShortcuts: () => getConfiguredShortcutsHandler(),
|
||||
registerGlobalShortcutsCore: input.registerGlobalShortcutsCore,
|
||||
toggleVisibleOverlay: () => input.overlay.getOverlayUi()?.toggleVisibleOverlay(),
|
||||
openYomitanSettings: () => {
|
||||
input.actions.openYomitanSettings();
|
||||
},
|
||||
isDev: input.isDev,
|
||||
getMainWindow: () => input.overlay.overlayManager.getMainWindow(),
|
||||
}),
|
||||
buildRefreshGlobalAndOverlayShortcutsMainDeps: (registerGlobalShortcutsHandler) => ({
|
||||
unregisterAllGlobalShortcuts: () => input.globalShortcut.unregisterAll(),
|
||||
registerGlobalShortcuts: () => registerGlobalShortcutsHandler(),
|
||||
syncOverlayShortcuts: () => shortcuts.syncOverlayShortcuts(),
|
||||
}),
|
||||
},
|
||||
numericShortcutRuntimeMainDeps: {
|
||||
globalShortcut: input.globalShortcut,
|
||||
showMpvOsd: (text) => input.actions.showMpvOsd(text),
|
||||
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
|
||||
clearTimer: (timer) => clearTimeout(timer),
|
||||
},
|
||||
numericSessions: {
|
||||
onMultiCopyDigit: (count) => input.actions.handleMultiCopyDigit(count),
|
||||
onMineSentenceDigit: (count) => input.mining.handleMineSentenceDigit(count),
|
||||
},
|
||||
overlayShortcuts: {
|
||||
getConfiguredShortcuts: () => shortcuts.getConfiguredShortcuts(),
|
||||
getShortcutsRegistered: () => input.appState.shortcutsRegistered,
|
||||
setShortcutsRegistered: (registered: boolean) => {
|
||||
input.appState.shortcutsRegistered = registered;
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => input.appState.overlayRuntimeInitialized,
|
||||
isOverlayShortcutContextActive: () => {
|
||||
if (process.platform !== 'win32') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!input.overlay.overlayManager.getVisibleOverlayVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const windowTracker = input.appState.windowTracker;
|
||||
if (!windowTracker || !windowTracker.isTracking()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return windowTracker.isTargetWindowFocused();
|
||||
},
|
||||
showMpvOsd: (text: string) => input.actions.showMpvOsd(text),
|
||||
openRuntimeOptionsPalette: () => {
|
||||
input.overlay.getOverlayUi()?.openRuntimeOptionsPalette();
|
||||
},
|
||||
openJimaku: () => {
|
||||
input.overlay.overlayModalRuntime.sendToActiveOverlayWindow('jimaku:open', undefined, {
|
||||
restoreOnModalClose: 'jimaku' as OverlayHostedModal,
|
||||
});
|
||||
},
|
||||
markAudioCard: () => input.mining.markLastCardAsAudioCard(),
|
||||
copySubtitle: () => input.mining.copyCurrentSubtitle(),
|
||||
toggleSecondarySubMode: () => input.actions.handleCycleSecondarySubMode(),
|
||||
updateLastCardFromClipboard: () => input.mining.updateLastCardFromClipboard(),
|
||||
triggerFieldGrouping: () => input.mining.triggerFieldGrouping(),
|
||||
triggerSubsyncFromConfig: () => input.actions.triggerSubsyncFromConfig(),
|
||||
mineSentenceCard: () => input.mining.mineSentenceCard(),
|
||||
},
|
||||
});
|
||||
|
||||
shortcuts = bootstrap.shortcuts;
|
||||
return bootstrap;
|
||||
}
|
||||
57
src/main/startup-flags.ts
Normal file
57
src/main/startup-flags.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import { isStandaloneTexthookerCommand, shouldRunSettingsOnlyStartup } from '../cli/args';
|
||||
|
||||
export function getPasswordStoreArg(argv: string[]): string | null {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg?.startsWith('--password-store')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--password-store') {
|
||||
const value = argv[i + 1];
|
||||
if (value && !value.startsWith('--')) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const [prefix, value] = arg.split('=', 2);
|
||||
if (prefix === '--password-store' && value && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizePasswordStoreArg(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized.toLowerCase() === 'gnome') {
|
||||
return 'gnome-libsecret';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getDefaultPasswordStore(): string {
|
||||
return 'gnome-libsecret';
|
||||
}
|
||||
|
||||
export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
||||
shouldUseMinimalStartup: boolean;
|
||||
shouldSkipHeavyStartup: boolean;
|
||||
} {
|
||||
return {
|
||||
shouldUseMinimalStartup: Boolean(
|
||||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
||||
(initialArgs?.stats &&
|
||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||
),
|
||||
shouldSkipHeavyStartup: Boolean(
|
||||
initialArgs &&
|
||||
(shouldRunSettingsOnlyStartup(initialArgs) ||
|
||||
initialArgs.stats ||
|
||||
initialArgs.dictionary ||
|
||||
initialArgs.setup),
|
||||
),
|
||||
};
|
||||
}
|
||||
41
src/main/startup-lifecycle-runtime.ts
Normal file
41
src/main/startup-lifecycle-runtime.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
composeStartupLifecycleHandlers,
|
||||
type StartupLifecycleComposerOptions,
|
||||
} from './runtime/composers';
|
||||
|
||||
export interface StartupLifecycleRuntimeInput {
|
||||
protocolUrl: StartupLifecycleComposerOptions['registerProtocolUrlHandlersMainDeps'];
|
||||
cleanup: StartupLifecycleComposerOptions['onWillQuitCleanupMainDeps'];
|
||||
shouldRestoreWindowsOnActivate: StartupLifecycleComposerOptions['shouldRestoreWindowsOnActivateMainDeps'];
|
||||
restoreWindowsOnActivate: StartupLifecycleComposerOptions['restoreWindowsOnActivateMainDeps'];
|
||||
}
|
||||
|
||||
export interface StartupLifecycleRuntime {
|
||||
registerProtocolUrlHandlers: () => void;
|
||||
onWillQuitCleanup: () => void;
|
||||
shouldRestoreWindowsOnActivate: () => boolean;
|
||||
restoreWindowsOnActivate: () => void;
|
||||
}
|
||||
|
||||
export function createStartupLifecycleRuntime(
|
||||
input: StartupLifecycleRuntimeInput,
|
||||
): StartupLifecycleRuntime {
|
||||
const {
|
||||
registerProtocolUrlHandlers,
|
||||
onWillQuitCleanup,
|
||||
shouldRestoreWindowsOnActivate,
|
||||
restoreWindowsOnActivate,
|
||||
} = composeStartupLifecycleHandlers({
|
||||
registerProtocolUrlHandlersMainDeps: input.protocolUrl,
|
||||
onWillQuitCleanupMainDeps: input.cleanup,
|
||||
shouldRestoreWindowsOnActivateMainDeps: input.shouldRestoreWindowsOnActivate,
|
||||
restoreWindowsOnActivateMainDeps: input.restoreWindowsOnActivate,
|
||||
});
|
||||
|
||||
return {
|
||||
registerProtocolUrlHandlers,
|
||||
onWillQuitCleanup,
|
||||
shouldRestoreWindowsOnActivate,
|
||||
restoreWindowsOnActivate,
|
||||
};
|
||||
}
|
||||
155
src/main/startup-sequence-runtime.test.ts
Normal file
155
src/main/startup-sequence-runtime.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createStartupSequenceRuntime } from './startup-sequence-runtime';
|
||||
|
||||
test('startup sequence delegates non-refresh headless command to initial args handler', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createStartupSequenceRuntime({
|
||||
appState: {
|
||||
initialArgs: { refreshKnownWords: false } as never,
|
||||
runtimeOptionsManager: null,
|
||||
},
|
||||
userDataPath: '/tmp/subminer',
|
||||
getResolvedConfig: () => ({ ankiConnect: { enabled: true } }) as never,
|
||||
anilist: {
|
||||
refreshAnilistClientSecretStateIfEnabled: async () => undefined,
|
||||
refreshRetryQueueState: () => {},
|
||||
},
|
||||
actions: {
|
||||
initializeDiscordPresenceService: async () => {},
|
||||
requestAppQuit: () => {},
|
||||
},
|
||||
logger: {
|
||||
error: () => {},
|
||||
},
|
||||
runHeadlessKnownWordRefresh: async () => {
|
||||
calls.push('refreshKnownWords');
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.runHeadlessInitialCommand({
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['handleInitialArgs']);
|
||||
});
|
||||
|
||||
test('startup sequence runs headless known-word refresh when requested', async () => {
|
||||
const calls: string[] = [];
|
||||
const runtimeOptionsManager = {
|
||||
getEffectiveAnkiConnectConfig: (config: never) => config,
|
||||
} as never;
|
||||
|
||||
const runtime = createStartupSequenceRuntime({
|
||||
appState: {
|
||||
initialArgs: { refreshKnownWords: true } as never,
|
||||
runtimeOptionsManager,
|
||||
},
|
||||
userDataPath: '/tmp/subminer',
|
||||
getResolvedConfig: () => ({ ankiConnect: { enabled: true } }) as never,
|
||||
anilist: {
|
||||
refreshAnilistClientSecretStateIfEnabled: async () => undefined,
|
||||
refreshRetryQueueState: () => {},
|
||||
},
|
||||
actions: {
|
||||
initializeDiscordPresenceService: async () => {},
|
||||
requestAppQuit: () => {
|
||||
calls.push('requestAppQuit');
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
error: () => {},
|
||||
},
|
||||
runHeadlessKnownWordRefresh: async (input) => {
|
||||
calls.push(`refresh:${input.userDataPath}`);
|
||||
assert.equal(input.runtimeOptionsManager, runtimeOptionsManager);
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.runHeadlessInitialCommand({
|
||||
handleInitialArgs: () => {
|
||||
calls.push('handleInitialArgs');
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['refresh:/tmp/subminer']);
|
||||
});
|
||||
|
||||
test('startup sequence runs deferred AniList and Discord init only for full startup', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createStartupSequenceRuntime({
|
||||
appState: {
|
||||
initialArgs: null,
|
||||
runtimeOptionsManager: null,
|
||||
},
|
||||
userDataPath: '/tmp/subminer',
|
||||
getResolvedConfig: () => ({ anilist: { enabled: true } }) as never,
|
||||
anilist: {
|
||||
refreshAnilistClientSecretStateIfEnabled: async (options) => {
|
||||
calls.push(`anilist:${options.force}:${options.allowSetupPrompt}`);
|
||||
},
|
||||
refreshRetryQueueState: () => {
|
||||
calls.push('retryQueue');
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
initializeDiscordPresenceService: async () => {
|
||||
calls.push('discord');
|
||||
},
|
||||
requestAppQuit: () => {},
|
||||
},
|
||||
logger: {
|
||||
error: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
runtime.runPostStartupInitialization();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(calls, ['anilist:true:false', 'retryQueue', 'discord']);
|
||||
});
|
||||
|
||||
test('startup sequence skips deferred startup side effects in minimal mode', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createStartupSequenceRuntime({
|
||||
appState: {
|
||||
initialArgs: { background: true } as never,
|
||||
runtimeOptionsManager: null,
|
||||
},
|
||||
userDataPath: '/tmp/subminer',
|
||||
getResolvedConfig: () => ({ anilist: { enabled: true } }) as never,
|
||||
anilist: {
|
||||
refreshAnilistClientSecretStateIfEnabled: async () => {
|
||||
calls.push('anilist');
|
||||
},
|
||||
refreshRetryQueueState: () => {
|
||||
calls.push('retryQueue');
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
initializeDiscordPresenceService: async () => {
|
||||
calls.push('discord');
|
||||
},
|
||||
requestAppQuit: () => {},
|
||||
},
|
||||
logger: {
|
||||
error: () => {},
|
||||
},
|
||||
getStartupModeFlags: () => ({
|
||||
shouldUseMinimalStartup: true,
|
||||
shouldSkipHeavyStartup: false,
|
||||
}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
});
|
||||
|
||||
runtime.runPostStartupInitialization();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
96
src/main/startup-sequence-runtime.ts
Normal file
96
src/main/startup-sequence-runtime.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import { isAnilistTrackingEnabled } from './runtime/domains/anilist';
|
||||
import { getStartupModeFlags } from './startup-flags';
|
||||
import { runHeadlessKnownWordRefresh } from './headless-known-word-refresh';
|
||||
|
||||
export interface StartupSequenceRuntimeInput {
|
||||
appState: {
|
||||
initialArgs: CliArgs | null | undefined;
|
||||
runtimeOptionsManager: Parameters<
|
||||
typeof runHeadlessKnownWordRefresh
|
||||
>[0]['runtimeOptionsManager'];
|
||||
};
|
||||
userDataPath: string;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
anilist: {
|
||||
refreshAnilistClientSecretStateIfEnabled: (options: {
|
||||
force: boolean;
|
||||
allowSetupPrompt?: boolean;
|
||||
}) => Promise<unknown>;
|
||||
refreshRetryQueueState: () => void;
|
||||
};
|
||||
actions: {
|
||||
initializeDiscordPresenceService: () => Promise<void>;
|
||||
requestAppQuit: () => void;
|
||||
};
|
||||
logger: {
|
||||
error: (message: string, error?: unknown) => void;
|
||||
};
|
||||
runHeadlessKnownWordRefresh?: typeof runHeadlessKnownWordRefresh;
|
||||
getStartupModeFlags?: typeof getStartupModeFlags;
|
||||
isAnilistTrackingEnabled?: typeof isAnilistTrackingEnabled;
|
||||
}
|
||||
|
||||
export interface StartupSequenceRuntime {
|
||||
runHeadlessInitialCommand: (input: { handleInitialArgs: () => void }) => Promise<void>;
|
||||
runPostStartupInitialization: () => void;
|
||||
}
|
||||
|
||||
export function createStartupSequenceRuntime(
|
||||
input: StartupSequenceRuntimeInput,
|
||||
): StartupSequenceRuntime {
|
||||
const runKnownWordRefresh = input.runHeadlessKnownWordRefresh ?? runHeadlessKnownWordRefresh;
|
||||
const resolveStartupModeFlags = input.getStartupModeFlags ?? getStartupModeFlags;
|
||||
const isTrackingEnabled = input.isAnilistTrackingEnabled ?? isAnilistTrackingEnabled;
|
||||
|
||||
const shouldSkipDeferredStartup = (): boolean => {
|
||||
if (!input.appState.initialArgs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startupModeFlags = resolveStartupModeFlags(input.appState.initialArgs);
|
||||
return startupModeFlags.shouldUseMinimalStartup || startupModeFlags.shouldSkipHeavyStartup;
|
||||
};
|
||||
|
||||
return {
|
||||
runHeadlessInitialCommand: async ({ handleInitialArgs }): Promise<void> => {
|
||||
if (!input.appState.initialArgs?.refreshKnownWords) {
|
||||
handleInitialArgs();
|
||||
return;
|
||||
}
|
||||
|
||||
await runKnownWordRefresh({
|
||||
resolvedConfig: input.getResolvedConfig(),
|
||||
runtimeOptionsManager: input.appState.runtimeOptionsManager,
|
||||
userDataPath: input.userDataPath,
|
||||
logger: input.logger,
|
||||
requestAppQuit: input.actions.requestAppQuit,
|
||||
});
|
||||
},
|
||||
runPostStartupInitialization: (): void => {
|
||||
if (shouldSkipDeferredStartup()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTrackingEnabled(input.getResolvedConfig())) {
|
||||
void input.anilist
|
||||
.refreshAnilistClientSecretStateIfEnabled({
|
||||
force: true,
|
||||
allowSetupPrompt: false,
|
||||
})
|
||||
.catch((error) => {
|
||||
input.logger.error(
|
||||
'Failed to refresh AniList client secret state during startup',
|
||||
error,
|
||||
);
|
||||
});
|
||||
input.anilist.refreshRetryQueueState();
|
||||
}
|
||||
|
||||
void input.actions.initializeDiscordPresenceService().catch((error) => {
|
||||
input.logger.error('Failed to initialize Discord presence service during startup', error);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
171
src/main/startup-support-coordinator.ts
Normal file
171
src/main/startup-support-coordinator.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MpvIpcClient } from '../core/services/mpv';
|
||||
import type {
|
||||
JimakuLanguagePreference,
|
||||
ResolvedConfig,
|
||||
SecondarySubMode,
|
||||
SubsyncManualPayload,
|
||||
} from '../types';
|
||||
import type { ConfigService } from '../config';
|
||||
import type { RuntimeOptionsManager } from '../runtime-options';
|
||||
import type { AppState } from './state';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import { createStartupSupportRuntime, type StartupSupportRuntime } from './startup-support-runtime';
|
||||
|
||||
export interface StartupSupportCoordinatorInput {
|
||||
platform: NodeJS.Platform;
|
||||
defaultImmersionDbPath: string;
|
||||
defaultJimakuLanguagePreference: JimakuLanguagePreference;
|
||||
defaultJimakuMaxEntryResults: number;
|
||||
defaultJimakuApiBaseUrl: string;
|
||||
jellyfinLangPref: string;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
appState: AppState;
|
||||
configService: Pick<ConfigService, 'reloadConfigStrict'>;
|
||||
actions: {
|
||||
sendMpvCommandRuntime: (client: MpvIpcClient, command: (string | number)[]) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
openSubsyncManualPicker: (payload: SubsyncManualPayload) => void;
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
showErrorBox: (title: string, details: string) => void;
|
||||
};
|
||||
logger: StartupSupportRuntime['configHotReloadRuntime'] extends never
|
||||
? never
|
||||
: Parameters<typeof createStartupSupportRuntime>[0]['logger'];
|
||||
watch: Parameters<typeof createStartupSupportRuntime>[0]['watch'];
|
||||
timers: Parameters<typeof createStartupSupportRuntime>[0]['timers'];
|
||||
}
|
||||
|
||||
export interface StartupSupportFromMainStateInput {
|
||||
platform: NodeJS.Platform;
|
||||
defaultImmersionDbPath: string;
|
||||
defaultJimakuLanguagePreference: JimakuLanguagePreference;
|
||||
defaultJimakuMaxEntryResults: number;
|
||||
defaultJimakuApiBaseUrl: string;
|
||||
jellyfinLangPref: string;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
appState: AppState;
|
||||
configService: Pick<ConfigService, 'reloadConfigStrict'>;
|
||||
overlay: {
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => void;
|
||||
};
|
||||
shortcuts: {
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
};
|
||||
notifications: {
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
showErrorBox: (title: string, details: string) => void;
|
||||
};
|
||||
logger: Parameters<typeof createStartupSupportRuntime>[0]['logger'];
|
||||
mpv: {
|
||||
sendMpvCommandRuntime: (client: MpvIpcClient, command: (string | number)[]) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function createStartupSupportCoordinator(
|
||||
input: StartupSupportCoordinatorInput,
|
||||
): StartupSupportRuntime {
|
||||
return createStartupSupportRuntime({
|
||||
platform: input.platform,
|
||||
defaultImmersionDbPath: input.defaultImmersionDbPath,
|
||||
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
|
||||
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
|
||||
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
|
||||
jellyfinLangPref: input.jellyfinLangPref,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
appState: {
|
||||
immersionTracker: input.appState.immersionTracker,
|
||||
mpvClient: input.appState.mpvClient,
|
||||
currentMediaPath: input.appState.currentMediaPath,
|
||||
currentMediaTitle: input.appState.currentMediaTitle,
|
||||
runtimeOptionsManager: input.appState.runtimeOptionsManager as RuntimeOptionsManager | null,
|
||||
subsyncInProgress: input.appState.subsyncInProgress,
|
||||
keybindings: input.appState.keybindings,
|
||||
ankiIntegration: input.appState.ankiIntegration,
|
||||
},
|
||||
mpv: {
|
||||
sendMpvCommandRuntime: (client, command) =>
|
||||
input.actions.sendMpvCommandRuntime(client as MpvIpcClient, command),
|
||||
showMpvOsd: (text) => input.actions.showMpvOsd(text),
|
||||
},
|
||||
config: {
|
||||
reloadConfigStrict: () => input.configService.reloadConfigStrict(),
|
||||
},
|
||||
subsync: {
|
||||
openManualPicker: (payload) => input.actions.openSubsyncManualPicker(payload),
|
||||
},
|
||||
hotReload: {
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => {
|
||||
input.appState.secondarySubMode = mode;
|
||||
},
|
||||
refreshGlobalAndOverlayShortcuts: () => input.actions.refreshGlobalAndOverlayShortcuts(),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
input.actions.broadcastToOverlayWindows(channel, payload),
|
||||
},
|
||||
notifications: {
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.actions.showDesktopNotification(title, options),
|
||||
showErrorBox: (title, details) => input.actions.showErrorBox(title, details),
|
||||
},
|
||||
logger: input.logger,
|
||||
watch: input.watch,
|
||||
timers: input.timers,
|
||||
});
|
||||
}
|
||||
|
||||
export function createStartupSupportFromMainState(
|
||||
input: StartupSupportFromMainStateInput,
|
||||
): StartupSupportRuntime {
|
||||
return createStartupSupportCoordinator({
|
||||
platform: input.platform,
|
||||
defaultImmersionDbPath: input.defaultImmersionDbPath,
|
||||
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
|
||||
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
|
||||
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
|
||||
jellyfinLangPref: input.jellyfinLangPref,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
appState: input.appState,
|
||||
configService: input.configService,
|
||||
actions: {
|
||||
sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command),
|
||||
showMpvOsd: (text) => input.mpv.showMpvOsd(text),
|
||||
openSubsyncManualPicker: (payload) => {
|
||||
input.overlay.sendToActiveOverlayWindow('subsync:open-manual', payload, {
|
||||
restoreOnModalClose: 'subsync',
|
||||
});
|
||||
},
|
||||
refreshGlobalAndOverlayShortcuts: () => {
|
||||
input.shortcuts.refreshGlobalAndOverlayShortcuts();
|
||||
},
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
input.overlay.broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.notifications.showDesktopNotification(title, options),
|
||||
showErrorBox: (title, details) => input.notifications.showErrorBox(title, details),
|
||||
},
|
||||
logger: input.logger,
|
||||
watch: {
|
||||
fileExists: (targetPath) => fs.existsSync(targetPath),
|
||||
dirname: (targetPath) => path.dirname(targetPath),
|
||||
watchPath: (targetPath, listener) => fs.watch(targetPath, listener),
|
||||
},
|
||||
timers: {
|
||||
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clearTimeout: (timeout) => clearTimeout(timeout),
|
||||
},
|
||||
});
|
||||
}
|
||||
235
src/main/startup-support-runtime.ts
Normal file
235
src/main/startup-support-runtime.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { createConfigHotReloadRuntime, type MpvIpcClient } from '../core/services';
|
||||
import type { MpvRuntimeClientLike } from '../core/services/mpv';
|
||||
import type {
|
||||
ConfigHotReloadPayload,
|
||||
ConfigValidationWarning,
|
||||
JimakuLanguagePreference,
|
||||
ResolvedConfig,
|
||||
SecondarySubMode,
|
||||
SubsyncManualPayload,
|
||||
} from '../types';
|
||||
import type { ReloadConfigStrictResult } from '../config';
|
||||
import { RuntimeOptionsManager } from '../runtime-options';
|
||||
import {
|
||||
createApplyJellyfinMpvDefaultsHandler,
|
||||
createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
|
||||
createBuildGetDefaultSocketPathMainDepsHandler,
|
||||
createGetDefaultSocketPathHandler,
|
||||
} from './runtime/domains/jellyfin';
|
||||
import {
|
||||
createBuildConfigHotReloadAppliedMainDepsHandler,
|
||||
createBuildConfigHotReloadMessageMainDepsHandler,
|
||||
createBuildConfigHotReloadRuntimeMainDepsHandler,
|
||||
createBuildWatchConfigPathMainDepsHandler,
|
||||
createConfigHotReloadAppliedHandler,
|
||||
createConfigHotReloadMessageHandler,
|
||||
createWatchConfigPathHandler,
|
||||
buildRestartRequiredConfigMessage,
|
||||
} from './runtime/domains/overlay';
|
||||
import {
|
||||
createBuildConfigDerivedRuntimeMainDepsHandler,
|
||||
createBuildImmersionMediaRuntimeMainDepsHandler,
|
||||
createBuildMainSubsyncRuntimeMainDepsHandler,
|
||||
createConfigDerivedRuntime,
|
||||
createImmersionMediaRuntime,
|
||||
createMainSubsyncRuntime,
|
||||
} from './runtime/domains/startup';
|
||||
import {
|
||||
buildConfigWarningDialogDetails,
|
||||
buildConfigWarningNotificationBody,
|
||||
} from './config-validation';
|
||||
|
||||
type ImmersionTrackerLike = {
|
||||
handleMediaChange: (path: string, title: string | null) => void;
|
||||
};
|
||||
|
||||
type MpvClientLike = MpvIpcClient | null;
|
||||
type JellyfinMpvClientLike = MpvRuntimeClientLike;
|
||||
|
||||
export interface StartupSupportRuntimeInput {
|
||||
platform: NodeJS.Platform;
|
||||
defaultImmersionDbPath: string;
|
||||
defaultJimakuLanguagePreference: JimakuLanguagePreference;
|
||||
defaultJimakuMaxEntryResults: number;
|
||||
defaultJimakuApiBaseUrl: string;
|
||||
jellyfinLangPref: string;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
appState: {
|
||||
immersionTracker: ImmersionTrackerLike | null;
|
||||
mpvClient: MpvClientLike;
|
||||
currentMediaPath: string | null;
|
||||
currentMediaTitle: string | null;
|
||||
runtimeOptionsManager: RuntimeOptionsManager | null;
|
||||
subsyncInProgress: boolean;
|
||||
keybindings: ConfigHotReloadPayload['keybindings'];
|
||||
ankiIntegration: {
|
||||
applyRuntimeConfigPatch: (patch: {
|
||||
ai: ResolvedConfig['ankiConnect']['ai']['enabled'];
|
||||
}) => void;
|
||||
} | null;
|
||||
};
|
||||
mpv: {
|
||||
sendMpvCommandRuntime: (client: JellyfinMpvClientLike, command: (string | number)[]) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
};
|
||||
config: {
|
||||
reloadConfigStrict: () => ReloadConfigStrictResult;
|
||||
};
|
||||
subsync: {
|
||||
openManualPicker: (payload: SubsyncManualPayload) => void;
|
||||
};
|
||||
hotReload: {
|
||||
setSecondarySubMode: (mode: SecondarySubMode) => void;
|
||||
refreshGlobalAndOverlayShortcuts: () => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
};
|
||||
notifications: {
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
showErrorBox: (title: string, details: string) => void;
|
||||
};
|
||||
logger: {
|
||||
debug: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, error?: unknown) => void;
|
||||
};
|
||||
watch: {
|
||||
fileExists: (targetPath: string) => boolean;
|
||||
dirname: (targetPath: string) => string;
|
||||
watchPath: (
|
||||
targetPath: string,
|
||||
listener: (eventType: string, filename: string | null) => void,
|
||||
) => { close: () => void };
|
||||
};
|
||||
timers: {
|
||||
setTimeout: (callback: () => void, delayMs: number) => NodeJS.Timeout;
|
||||
clearTimeout: (timeout: NodeJS.Timeout) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StartupSupportRuntime {
|
||||
applyJellyfinMpvDefaults: (client: JellyfinMpvClientLike) => void;
|
||||
getDefaultSocketPath: () => string;
|
||||
immersionMediaRuntime: ReturnType<typeof createImmersionMediaRuntime>;
|
||||
configDerivedRuntime: ReturnType<typeof createConfigDerivedRuntime>;
|
||||
subsyncRuntime: ReturnType<typeof createMainSubsyncRuntime>;
|
||||
configHotReloadRuntime: ReturnType<typeof createConfigHotReloadRuntime>;
|
||||
}
|
||||
|
||||
export function createStartupSupportRuntime(
|
||||
input: StartupSupportRuntimeInput,
|
||||
): StartupSupportRuntime {
|
||||
const applyJellyfinMpvDefaultsHandler = createApplyJellyfinMpvDefaultsHandler(
|
||||
createBuildApplyJellyfinMpvDefaultsMainDepsHandler({
|
||||
sendMpvCommandRuntime: (client, command) => input.mpv.sendMpvCommandRuntime(client, command),
|
||||
jellyfinLangPref: input.jellyfinLangPref,
|
||||
})(),
|
||||
);
|
||||
|
||||
const getDefaultSocketPathHandler = createGetDefaultSocketPathHandler(
|
||||
createBuildGetDefaultSocketPathMainDepsHandler({
|
||||
platform: input.platform,
|
||||
})(),
|
||||
);
|
||||
|
||||
const immersionMediaRuntime = createImmersionMediaRuntime(
|
||||
createBuildImmersionMediaRuntimeMainDepsHandler({
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
defaultImmersionDbPath: input.defaultImmersionDbPath,
|
||||
getTracker: () => input.appState.immersionTracker,
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
||||
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
|
||||
logDebug: (message) => input.logger.debug(message),
|
||||
logInfo: (message) => input.logger.info(message),
|
||||
})(),
|
||||
);
|
||||
|
||||
const configDerivedRuntime = createConfigDerivedRuntime(
|
||||
createBuildConfigDerivedRuntimeMainDepsHandler({
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
getRuntimeOptionsManager: () => input.appState.runtimeOptionsManager,
|
||||
defaultJimakuLanguagePreference: input.defaultJimakuLanguagePreference,
|
||||
defaultJimakuMaxEntryResults: input.defaultJimakuMaxEntryResults,
|
||||
defaultJimakuApiBaseUrl: input.defaultJimakuApiBaseUrl,
|
||||
})(),
|
||||
);
|
||||
|
||||
const subsyncRuntime = createMainSubsyncRuntime(
|
||||
createBuildMainSubsyncRuntimeMainDepsHandler({
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
getSubsyncInProgress: () => input.appState.subsyncInProgress,
|
||||
setSubsyncInProgress: (inProgress) => {
|
||||
input.appState.subsyncInProgress = inProgress;
|
||||
},
|
||||
showMpvOsd: (text) => input.mpv.showMpvOsd(text),
|
||||
openManualPicker: (payload) => input.subsync.openManualPicker(payload),
|
||||
})(),
|
||||
);
|
||||
|
||||
const notifyConfigHotReloadMessage = createConfigHotReloadMessageHandler(
|
||||
createBuildConfigHotReloadMessageMainDepsHandler({
|
||||
showMpvOsd: (message) => input.mpv.showMpvOsd(message),
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.notifications.showDesktopNotification(title, options),
|
||||
})(),
|
||||
);
|
||||
|
||||
const watchConfigPathHandler = createWatchConfigPathHandler(
|
||||
createBuildWatchConfigPathMainDepsHandler({
|
||||
fileExists: (targetPath) => input.watch.fileExists(targetPath),
|
||||
dirname: (targetPath) => input.watch.dirname(targetPath),
|
||||
watchPath: (targetPath, listener) => input.watch.watchPath(targetPath, listener),
|
||||
})(),
|
||||
);
|
||||
|
||||
const configHotReloadRuntime = createConfigHotReloadRuntime(
|
||||
createBuildConfigHotReloadRuntimeMainDepsHandler({
|
||||
getCurrentConfig: () => input.getResolvedConfig(),
|
||||
reloadConfigStrict: () => input.config.reloadConfigStrict(),
|
||||
watchConfigPath: (configPath, onChange) => watchConfigPathHandler(configPath, onChange),
|
||||
setTimeout: (callback, delayMs) => input.timers.setTimeout(callback, delayMs),
|
||||
clearTimeout: (timeout) => input.timers.clearTimeout(timeout),
|
||||
debounceMs: 250,
|
||||
onHotReloadApplied: createConfigHotReloadAppliedHandler(
|
||||
createBuildConfigHotReloadAppliedMainDepsHandler({
|
||||
setKeybindings: (keybindings) => {
|
||||
input.appState.keybindings = keybindings;
|
||||
},
|
||||
refreshGlobalAndOverlayShortcuts: () => {
|
||||
input.hotReload.refreshGlobalAndOverlayShortcuts();
|
||||
},
|
||||
setSecondarySubMode: (mode) => input.hotReload.setSecondarySubMode(mode),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
input.hotReload.broadcastToOverlayWindows(channel, payload),
|
||||
applyAnkiRuntimeConfigPatch: (patch) => {
|
||||
input.appState.ankiIntegration?.applyRuntimeConfigPatch(patch);
|
||||
},
|
||||
})(),
|
||||
),
|
||||
onRestartRequired: (fields) =>
|
||||
notifyConfigHotReloadMessage(buildRestartRequiredConfigMessage(fields)),
|
||||
onInvalidConfig: notifyConfigHotReloadMessage,
|
||||
onValidationWarnings: (configPath, warnings: ConfigValidationWarning[]) => {
|
||||
input.notifications.showDesktopNotification('SubMiner', {
|
||||
body: buildConfigWarningNotificationBody(configPath, warnings),
|
||||
});
|
||||
if (input.platform === 'darwin') {
|
||||
input.notifications.showErrorBox(
|
||||
'SubMiner config validation warning',
|
||||
buildConfigWarningDialogDetails(configPath, warnings),
|
||||
);
|
||||
}
|
||||
},
|
||||
})(),
|
||||
);
|
||||
|
||||
return {
|
||||
applyJellyfinMpvDefaults: (client) => applyJellyfinMpvDefaultsHandler(client),
|
||||
getDefaultSocketPath: () => getDefaultSocketPathHandler(),
|
||||
immersionMediaRuntime,
|
||||
configDerivedRuntime,
|
||||
subsyncRuntime,
|
||||
configHotReloadRuntime,
|
||||
};
|
||||
}
|
||||
185
src/main/stats-runtime-coordinator.ts
Normal file
185
src/main/stats-runtime-coordinator.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { shell } from 'electron';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import {
|
||||
addYomitanNoteViaSearch,
|
||||
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
||||
} from '../core/services';
|
||||
import { createLogger } from '../logger';
|
||||
import type { AppState } from './state';
|
||||
import {
|
||||
createStatsRuntimeBootstrap,
|
||||
type StatsRuntime,
|
||||
type StatsRuntimeBootstrap,
|
||||
} from './stats-runtime';
|
||||
import { registerStatsOverlayToggle, destroyStatsWindow } from '../core/services/stats-window.js';
|
||||
|
||||
export interface StatsRuntimeCoordinatorInput {
|
||||
statsDaemonStatePath: string;
|
||||
statsDistPath: string;
|
||||
statsPreloadPath: string;
|
||||
userDataPath: string;
|
||||
appState: AppState;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
dictionarySupport: {
|
||||
getConfiguredDbPath: () => string;
|
||||
seedImmersionMediaFromCurrentMedia: () => Promise<void> | void;
|
||||
};
|
||||
overlay: {
|
||||
getOverlayGeometry: () => { getCurrentOverlayGeometry: () => Electron.Rectangle };
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
registerStatsOverlayToggle: StatsRuntimeBootstrap['stats'] extends never
|
||||
? never
|
||||
: Parameters<typeof createStatsRuntimeBootstrap>[0]['overlay']['registerStatsOverlayToggle'];
|
||||
};
|
||||
mpvRuntime: {
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
};
|
||||
actions: {
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
requestAppQuit: () => void;
|
||||
destroyStatsWindow: () => void;
|
||||
};
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, error?: unknown) => void;
|
||||
error: (message: string, error?: unknown) => void;
|
||||
debug: (message: string, details?: unknown) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatsRuntimeCoordinator {
|
||||
statsBootstrap: StatsRuntimeBootstrap;
|
||||
stats: StatsRuntime;
|
||||
ensureStatsServerStarted: () => string;
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
};
|
||||
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
runStatsCliCommand: (
|
||||
args: Pick<
|
||||
CliArgs,
|
||||
| 'statsResponsePath'
|
||||
| 'statsBackground'
|
||||
| 'statsStop'
|
||||
| 'statsCleanup'
|
||||
| 'statsCleanupVocab'
|
||||
| 'statsCleanupLifetime'
|
||||
>,
|
||||
source: CliCommandSource,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createStatsRuntimeCoordinator(
|
||||
input: StatsRuntimeCoordinatorInput,
|
||||
): StatsRuntimeCoordinator {
|
||||
const statsBootstrap = createStatsRuntimeBootstrap({
|
||||
statsDaemonStatePath: input.statsDaemonStatePath,
|
||||
statsDistPath: input.statsDistPath,
|
||||
statsPreloadPath: input.statsPreloadPath,
|
||||
userDataPath: input.userDataPath,
|
||||
appState: input.appState,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
dictionarySupport: input.dictionarySupport,
|
||||
overlay: input.overlay,
|
||||
createMecabTokenizerAndCheck: async () => {
|
||||
await input.mpvRuntime.createMecabTokenizerAndCheck();
|
||||
},
|
||||
addYomitanNote: async (word: string) => {
|
||||
const yomitanDeps = {
|
||||
getYomitanExt: () => input.appState.yomitanExt,
|
||||
getYomitanSession: () => input.appState.yomitanSession,
|
||||
getYomitanParserWindow: () => input.appState.yomitanParserWindow,
|
||||
setYomitanParserWindow: (window: BrowserWindow | null) => {
|
||||
input.appState.yomitanParserWindow = window;
|
||||
},
|
||||
getYomitanParserReadyPromise: () => input.appState.yomitanParserReadyPromise,
|
||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => {
|
||||
input.appState.yomitanParserReadyPromise = promise;
|
||||
},
|
||||
getYomitanParserInitPromise: () => input.appState.yomitanParserInitPromise,
|
||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => {
|
||||
input.appState.yomitanParserInitPromise = promise;
|
||||
},
|
||||
};
|
||||
const yomitanLogger = createLogger('main:yomitan-stats');
|
||||
const ankiUrl = input.getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
|
||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||
forceOverride: true,
|
||||
});
|
||||
return addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||
},
|
||||
openExternal: input.actions.openExternal,
|
||||
requestAppQuit: input.actions.requestAppQuit,
|
||||
destroyStatsWindow: input.actions.destroyStatsWindow,
|
||||
logger: input.logger,
|
||||
});
|
||||
|
||||
const stats = statsBootstrap.stats;
|
||||
|
||||
return {
|
||||
statsBootstrap,
|
||||
stats,
|
||||
ensureStatsServerStarted: () => stats.ensureStatsServerStarted(),
|
||||
ensureBackgroundStatsServerStarted: () => stats.ensureBackgroundStatsServerStarted(),
|
||||
stopBackgroundStatsServer: async () => await stats.stopBackgroundStatsServer(),
|
||||
ensureImmersionTrackerStarted: () => {
|
||||
stats.ensureImmersionTrackerStarted();
|
||||
},
|
||||
runStatsCliCommand: async (args, source) => {
|
||||
await stats.runStatsCliCommand(args, source);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatsRuntimeFromMainStateInput {
|
||||
dirname: string;
|
||||
userDataPath: string;
|
||||
appState: AppState;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
dictionarySupport: StatsRuntimeCoordinatorInput['dictionarySupport'];
|
||||
overlay: {
|
||||
getOverlayGeometry: () => { getCurrentOverlayGeometry: () => Electron.Rectangle };
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
};
|
||||
mpvRuntime: {
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
};
|
||||
actions: {
|
||||
requestAppQuit: () => void;
|
||||
};
|
||||
logger: StatsRuntimeCoordinatorInput['logger'];
|
||||
}
|
||||
|
||||
export function createStatsRuntimeFromMainState(
|
||||
input: StatsRuntimeFromMainStateInput,
|
||||
): StatsRuntimeCoordinator {
|
||||
return createStatsRuntimeCoordinator({
|
||||
statsDaemonStatePath: path.join(input.userDataPath, 'stats-daemon.json'),
|
||||
statsDistPath: path.join(input.dirname, '..', 'stats', 'dist'),
|
||||
statsPreloadPath: path.join(input.dirname, 'preload-stats.js'),
|
||||
userDataPath: input.userDataPath,
|
||||
appState: input.appState,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
dictionarySupport: input.dictionarySupport,
|
||||
overlay: {
|
||||
getOverlayGeometry: () => input.overlay.getOverlayGeometry(),
|
||||
updateVisibleOverlayVisibility: () => input.overlay.updateVisibleOverlayVisibility(),
|
||||
registerStatsOverlayToggle,
|
||||
},
|
||||
mpvRuntime: {
|
||||
createMecabTokenizerAndCheck: () => input.mpvRuntime.createMecabTokenizerAndCheck(),
|
||||
},
|
||||
actions: {
|
||||
openExternal: (url) => shell.openExternal(url),
|
||||
requestAppQuit: () => input.actions.requestAppQuit(),
|
||||
destroyStatsWindow,
|
||||
},
|
||||
logger: input.logger,
|
||||
});
|
||||
}
|
||||
131
src/main/stats-runtime.test.ts
Normal file
131
src/main/stats-runtime.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createStatsRuntime } from './stats-runtime';
|
||||
|
||||
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-runtime-test-'));
|
||||
const result = fn(dir);
|
||||
if (result instanceof Promise) {
|
||||
return result.finally(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
test('stats runtime removes stale daemon state', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const statePath = path.join(dir, 'stats-daemon.json');
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({ pid: 99999, port: 6969, startedAtMs: 1_234 }, null, 2),
|
||||
);
|
||||
|
||||
const runtime = createStatsRuntime({
|
||||
statsDaemonStatePath: statePath,
|
||||
getResolvedConfig: () => ({
|
||||
immersionTracking: { enabled: true },
|
||||
stats: { serverPort: 6969 },
|
||||
}),
|
||||
getImmersionTracker: () => ({}) as never,
|
||||
ensureImmersionTrackerStartedCore: () => {},
|
||||
startStatsServer: () => ({ close: () => {} }),
|
||||
openExternal: async () => {},
|
||||
exitAppWithCode: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
getCurrentPid: () => 123,
|
||||
isProcessAlive: () => false,
|
||||
});
|
||||
|
||||
assert.equal(runtime.readLiveBackgroundStatsDaemonState(), null);
|
||||
assert.equal(fs.existsSync(statePath), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('stats runtime starts background server and writes owned daemon state', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const statePath = path.join(dir, 'stats-daemon.json');
|
||||
let startedPort: number | null = null;
|
||||
|
||||
const runtime = createStatsRuntime({
|
||||
statsDaemonStatePath: statePath,
|
||||
getResolvedConfig: () => ({
|
||||
immersionTracking: { enabled: true },
|
||||
stats: { serverPort: 6970 },
|
||||
}),
|
||||
getImmersionTracker: () => ({}) as never,
|
||||
ensureImmersionTrackerStartedCore: () => {},
|
||||
startStatsServer: (port) => {
|
||||
startedPort = port;
|
||||
return { close: () => {} };
|
||||
},
|
||||
openExternal: async () => {},
|
||||
exitAppWithCode: () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
getCurrentPid: () => 456,
|
||||
isProcessAlive: () => false,
|
||||
now: () => 999,
|
||||
});
|
||||
|
||||
const result = runtime.ensureBackgroundStatsServerStarted();
|
||||
|
||||
assert.deepEqual(result, {
|
||||
url: 'http://127.0.0.1:6970',
|
||||
runningInCurrentProcess: true,
|
||||
});
|
||||
assert.equal(startedPort, 6970);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
|
||||
pid: 456,
|
||||
port: 6970,
|
||||
startedAtMs: 999,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('stats runtime stops owned server and clears daemon state during quit cleanup', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const statePath = path.join(dir, 'stats-daemon.json');
|
||||
const calls: string[] = [];
|
||||
|
||||
const runtime = createStatsRuntime({
|
||||
statsDaemonStatePath: statePath,
|
||||
getResolvedConfig: () => ({
|
||||
immersionTracking: { enabled: true },
|
||||
stats: { serverPort: 6971 },
|
||||
}),
|
||||
getImmersionTracker: () => ({}) as never,
|
||||
ensureImmersionTrackerStartedCore: () => {},
|
||||
startStatsServer: () => ({
|
||||
close: () => {
|
||||
calls.push('close');
|
||||
},
|
||||
}),
|
||||
openExternal: async () => {},
|
||||
exitAppWithCode: () => {},
|
||||
destroyStatsWindow: () => {
|
||||
calls.push('destroy-window');
|
||||
},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logError: () => {},
|
||||
getCurrentPid: () => 789,
|
||||
isProcessAlive: () => true,
|
||||
now: () => 500,
|
||||
});
|
||||
|
||||
runtime.ensureBackgroundStatsServerStarted();
|
||||
runtime.cleanupBeforeQuit();
|
||||
|
||||
assert.deepEqual(calls, ['destroy-window', 'close']);
|
||||
assert.equal(fs.existsSync(statePath), false);
|
||||
assert.equal(runtime.getStatsServer(), null);
|
||||
});
|
||||
});
|
||||
469
src/main/stats-runtime.ts
Normal file
469
src/main/stats-runtime.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import path from 'node:path';
|
||||
import {
|
||||
isBackgroundStatsServerProcessAlive,
|
||||
readBackgroundStatsServerState,
|
||||
removeBackgroundStatsServerState,
|
||||
resolveBackgroundStatsServerUrl,
|
||||
writeBackgroundStatsServerState,
|
||||
type BackgroundStatsServerState,
|
||||
} from './runtime/stats-daemon';
|
||||
import {
|
||||
createRunStatsCliCommandHandler,
|
||||
writeStatsCliCommandResponse,
|
||||
} from './runtime/stats-cli-command';
|
||||
import type { CliArgs, CliCommandSource } from '../cli/args';
|
||||
import { ImmersionTrackerService } from '../core/services/immersion-tracker-service';
|
||||
import { startStatsServer as startStatsServerCore } from '../core/services/stats-server';
|
||||
import { createLogger } from '../logger';
|
||||
import { createCoverArtFetcher } from '../core/services/anilist/cover-art-fetcher';
|
||||
import { createAnilistRateLimiter } from '../core/services/anilist/rate-limiter';
|
||||
import { resolveLegacyVocabularyPosFromTokens } from '../core/services/immersion-tracker/legacy-vocabulary-pos';
|
||||
import type {
|
||||
LifetimeRebuildSummary,
|
||||
VocabularyCleanupSummary,
|
||||
} from '../core/services/immersion-tracker/types';
|
||||
import type { ResolvedConfig } from '../types';
|
||||
import type { AppReadyImmersionInput } from './app-ready-runtime';
|
||||
import { createImmersionTrackerStartupHandler } from './runtime/immersion-startup';
|
||||
|
||||
type StatsConfigLike = {
|
||||
immersionTracking?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
stats: {
|
||||
serverPort: number;
|
||||
autoOpenBrowser?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type StatsServerLike = {
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type StatsTrackerLike = {
|
||||
cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary>;
|
||||
rebuildLifetimeSummaries?: () => Promise<LifetimeRebuildSummary>;
|
||||
recordCardsMined?: (count: number, noteIds?: number[]) => void;
|
||||
};
|
||||
|
||||
type StatsBootstrapAppState = {
|
||||
mecabTokenizer: {
|
||||
tokenize: (text: string) => Promise<unknown[] | null>;
|
||||
} | null;
|
||||
immersionTracker: ImmersionTrackerService | null;
|
||||
mpvClient: unknown | null;
|
||||
mpvSocketPath: string;
|
||||
ankiIntegration: {
|
||||
resolveCurrentNoteId: (noteId: number) => number;
|
||||
} | null;
|
||||
statsOverlayVisible: boolean;
|
||||
};
|
||||
|
||||
export interface StatsRuntimeInput<
|
||||
TConfig extends StatsConfigLike = StatsConfigLike,
|
||||
TTracker extends StatsTrackerLike = StatsTrackerLike,
|
||||
TServer extends StatsServerLike = StatsServerLike,
|
||||
> {
|
||||
statsDaemonStatePath: string;
|
||||
getResolvedConfig: () => TConfig;
|
||||
getImmersionTracker: () => TTracker | null;
|
||||
ensureImmersionTrackerStartedCore: () => void;
|
||||
ensureVocabularyCleanupTokenizerReady?: () => Promise<void> | void;
|
||||
startStatsServer: (port: number) => TServer;
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
exitAppWithCode: (code: number) => void;
|
||||
destroyStatsWindow?: () => void;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, error?: unknown) => void;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
now?: () => number;
|
||||
getCurrentPid?: () => number;
|
||||
isProcessAlive?: (pid: number) => boolean;
|
||||
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
|
||||
wait?: (delayMs: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface StatsRuntime {
|
||||
readLiveBackgroundStatsDaemonState: () => BackgroundStatsServerState | null;
|
||||
ensureImmersionTrackerStarted: () => void;
|
||||
ensureStatsServerStarted: () => string;
|
||||
stopStatsServer: () => void;
|
||||
ensureBackgroundStatsServerStarted: () => {
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
};
|
||||
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
|
||||
runStatsCliCommand: (
|
||||
args: Pick<
|
||||
CliArgs,
|
||||
| 'statsResponsePath'
|
||||
| 'statsBackground'
|
||||
| 'statsStop'
|
||||
| 'statsCleanup'
|
||||
| 'statsCleanupVocab'
|
||||
| 'statsCleanupLifetime'
|
||||
>,
|
||||
source: CliCommandSource,
|
||||
) => Promise<void>;
|
||||
cleanupBeforeQuit: () => void;
|
||||
getStatsServer: () => StatsServerLike | null;
|
||||
isStatsStartupInProgress: () => boolean;
|
||||
}
|
||||
|
||||
export interface StatsRuntimeBootstrapInput {
|
||||
statsDaemonStatePath: string;
|
||||
statsDistPath: string;
|
||||
statsPreloadPath: string;
|
||||
userDataPath: string;
|
||||
appState: StatsBootstrapAppState;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
dictionarySupport: {
|
||||
getConfiguredDbPath: () => string;
|
||||
seedImmersionMediaFromCurrentMedia: () => Promise<void> | void;
|
||||
};
|
||||
overlay: {
|
||||
getOverlayGeometry: () => { getCurrentOverlayGeometry: () => Electron.Rectangle };
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
registerStatsOverlayToggle: (options: {
|
||||
staticDir: string;
|
||||
preloadPath: string;
|
||||
getApiBaseUrl: () => string;
|
||||
getToggleKey: () => string;
|
||||
resolveBounds: () => Electron.Rectangle;
|
||||
onVisibilityChanged: (visible: boolean) => void;
|
||||
}) => void;
|
||||
};
|
||||
createMecabTokenizerAndCheck: () => Promise<void>;
|
||||
addYomitanNote: (word: string) => Promise<number | null>;
|
||||
openExternal: (url: string) => Promise<unknown>;
|
||||
requestAppQuit: () => void;
|
||||
destroyStatsWindow: () => void;
|
||||
logger: {
|
||||
info: (message: string) => void;
|
||||
warn: (message: string, error?: unknown) => void;
|
||||
error: (message: string, error?: unknown) => void;
|
||||
debug: (message: string, details?: unknown) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatsRuntimeBootstrap {
|
||||
stats: StatsRuntime;
|
||||
immersion: AppReadyImmersionInput;
|
||||
recordTrackedCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}
|
||||
|
||||
export function createStatsRuntime<
|
||||
TConfig extends StatsConfigLike,
|
||||
TTracker extends StatsTrackerLike,
|
||||
TServer extends StatsServerLike,
|
||||
>(input: StatsRuntimeInput<TConfig, TTracker, TServer>): StatsRuntime {
|
||||
const now = input.now ?? Date.now;
|
||||
const getCurrentPid = input.getCurrentPid ?? (() => process.pid);
|
||||
const isProcessAlive = input.isProcessAlive ?? isBackgroundStatsServerProcessAlive;
|
||||
const killProcess =
|
||||
input.killProcess ??
|
||||
((pid: number, signal: NodeJS.Signals) => {
|
||||
process.kill(pid, signal);
|
||||
});
|
||||
const wait =
|
||||
input.wait ??
|
||||
(async (delayMs: number) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
});
|
||||
|
||||
let statsServer: TServer | null = null;
|
||||
let statsStartupInProgress = false;
|
||||
let hasAttemptedImmersionTrackerStartup = false;
|
||||
|
||||
const readLiveBackgroundStatsDaemonState = (): BackgroundStatsServerState | null => {
|
||||
const state = readBackgroundStatsServerState(input.statsDaemonStatePath);
|
||||
if (!state) {
|
||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
||||
return null;
|
||||
}
|
||||
if (state.pid === getCurrentPid() && !statsServer) {
|
||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
||||
return null;
|
||||
}
|
||||
if (!isProcessAlive(state.pid)) {
|
||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
||||
return null;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
const clearOwnedBackgroundStatsDaemonState = (): void => {
|
||||
const state = readBackgroundStatsServerState(input.statsDaemonStatePath);
|
||||
if (state?.pid === getCurrentPid()) {
|
||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
||||
}
|
||||
};
|
||||
|
||||
const stopStatsServer = (): void => {
|
||||
if (!statsServer) {
|
||||
return;
|
||||
}
|
||||
statsServer.close();
|
||||
statsServer = null;
|
||||
clearOwnedBackgroundStatsDaemonState();
|
||||
};
|
||||
|
||||
const ensureImmersionTrackerStarted = (): void => {
|
||||
if (hasAttemptedImmersionTrackerStartup || input.getImmersionTracker()) {
|
||||
return;
|
||||
}
|
||||
hasAttemptedImmersionTrackerStartup = true;
|
||||
statsStartupInProgress = true;
|
||||
try {
|
||||
input.ensureImmersionTrackerStartedCore();
|
||||
} finally {
|
||||
statsStartupInProgress = false;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureStatsServerStarted = (): string => {
|
||||
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
||||
if (liveDaemon && liveDaemon.pid !== getCurrentPid()) {
|
||||
return resolveBackgroundStatsServerUrl(liveDaemon);
|
||||
}
|
||||
|
||||
const tracker = input.getImmersionTracker();
|
||||
if (!tracker) {
|
||||
throw new Error('Immersion tracker failed to initialize.');
|
||||
}
|
||||
|
||||
if (!statsServer) {
|
||||
statsServer = input.startStatsServer(input.getResolvedConfig().stats.serverPort);
|
||||
}
|
||||
|
||||
return `http://127.0.0.1:${input.getResolvedConfig().stats.serverPort}`;
|
||||
};
|
||||
|
||||
const ensureBackgroundStatsServerStarted = (): {
|
||||
url: string;
|
||||
runningInCurrentProcess: boolean;
|
||||
} => {
|
||||
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
||||
if (liveDaemon && liveDaemon.pid !== getCurrentPid()) {
|
||||
return {
|
||||
url: resolveBackgroundStatsServerUrl(liveDaemon),
|
||||
runningInCurrentProcess: false,
|
||||
};
|
||||
}
|
||||
|
||||
ensureImmersionTrackerStarted();
|
||||
const url = ensureStatsServerStarted();
|
||||
writeBackgroundStatsServerState(input.statsDaemonStatePath, {
|
||||
pid: getCurrentPid(),
|
||||
port: input.getResolvedConfig().stats.serverPort,
|
||||
startedAtMs: now(),
|
||||
});
|
||||
return {
|
||||
url,
|
||||
runningInCurrentProcess: true,
|
||||
};
|
||||
};
|
||||
|
||||
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
|
||||
const state = readBackgroundStatsServerState(input.statsDaemonStatePath);
|
||||
if (!state) {
|
||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
||||
return { ok: true, stale: true };
|
||||
}
|
||||
if (!isProcessAlive(state.pid)) {
|
||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
||||
return { ok: true, stale: true };
|
||||
}
|
||||
|
||||
try {
|
||||
killProcess(state.pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
|
||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
||||
return { ok: true, stale: true };
|
||||
}
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'EPERM') {
|
||||
throw new Error(
|
||||
`Insufficient permissions to stop background stats server (pid ${state.pid}).`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const deadline = now() + 2_000;
|
||||
while (now() < deadline) {
|
||||
if (!isProcessAlive(state.pid)) {
|
||||
removeBackgroundStatsServerState(input.statsDaemonStatePath);
|
||||
return { ok: true, stale: false };
|
||||
}
|
||||
await wait(50);
|
||||
}
|
||||
|
||||
throw new Error('Timed out stopping background stats server.');
|
||||
};
|
||||
|
||||
const runStatsCliCommand = createRunStatsCliCommandHandler({
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(),
|
||||
ensureVocabularyCleanupTokenizerReady: input.ensureVocabularyCleanupTokenizerReady,
|
||||
getImmersionTracker: () => input.getImmersionTracker(),
|
||||
ensureStatsServerStarted: () => ensureStatsServerStarted(),
|
||||
ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(),
|
||||
stopBackgroundStatsServer: () => stopBackgroundStatsServer(),
|
||||
openExternal: async (url) => await input.openExternal(url),
|
||||
writeResponse: (responsePath, payload) => {
|
||||
writeStatsCliCommandResponse(responsePath, payload);
|
||||
},
|
||||
exitAppWithCode: (code) => input.exitAppWithCode(code),
|
||||
logInfo: (message) => input.logInfo(message),
|
||||
logWarn: (message, error) => input.logWarn(message, error),
|
||||
logError: (message, error) => input.logError(message, error),
|
||||
});
|
||||
|
||||
const cleanupBeforeQuit = (): void => {
|
||||
input.destroyStatsWindow?.();
|
||||
stopStatsServer();
|
||||
};
|
||||
|
||||
return {
|
||||
readLiveBackgroundStatsDaemonState,
|
||||
ensureImmersionTrackerStarted,
|
||||
ensureStatsServerStarted,
|
||||
stopStatsServer,
|
||||
ensureBackgroundStatsServerStarted,
|
||||
stopBackgroundStatsServer,
|
||||
runStatsCliCommand,
|
||||
cleanupBeforeQuit,
|
||||
getStatsServer: () => statsServer,
|
||||
isStatsStartupInProgress: () => statsStartupInProgress,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStatsRuntimeBootstrap(
|
||||
input: StatsRuntimeBootstrapInput,
|
||||
): StatsRuntimeBootstrap {
|
||||
const statsCoverArtFetcher = createCoverArtFetcher(
|
||||
createAnilistRateLimiter(),
|
||||
createLogger('main:stats-cover-art'),
|
||||
);
|
||||
const resolveLegacyVocabularyPos = async (row: {
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string | null;
|
||||
}) => {
|
||||
const tokenizer = input.appState.mecabTokenizer;
|
||||
if (!tokenizer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookupTexts = [...new Set([row.headword, row.word, row.reading ?? ''])]
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
|
||||
for (const lookupText of lookupTexts) {
|
||||
const tokens = await tokenizer.tokenize(lookupText);
|
||||
if (!tokens) {
|
||||
continue;
|
||||
}
|
||||
const resolved = resolveLegacyVocabularyPosFromTokens(lookupText, tokens as never);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
let stats: StatsRuntime | null = null;
|
||||
const immersionMainDeps: Parameters<typeof createImmersionTrackerStartupHandler>[0] = {
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
getConfiguredDbPath: () => input.dictionarySupport.getConfiguredDbPath(),
|
||||
createTrackerService: (params) =>
|
||||
new ImmersionTrackerService({
|
||||
...params,
|
||||
resolveLegacyVocabularyPos,
|
||||
}),
|
||||
setTracker: (tracker) => {
|
||||
const trackerHasChanged =
|
||||
input.appState.immersionTracker !== null && input.appState.immersionTracker !== tracker;
|
||||
if (trackerHasChanged && stats?.getStatsServer()) {
|
||||
stats.stopStatsServer();
|
||||
}
|
||||
|
||||
input.appState.immersionTracker = tracker as ImmersionTrackerService | null;
|
||||
input.appState.immersionTracker?.setCoverArtFetcher?.(statsCoverArtFetcher);
|
||||
if (!tracker) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stats?.getStatsServer() && input.getResolvedConfig().stats.autoStartServer) {
|
||||
stats?.ensureStatsServerStarted();
|
||||
}
|
||||
|
||||
input.overlay.registerStatsOverlayToggle({
|
||||
staticDir: input.statsDistPath,
|
||||
preloadPath: input.statsPreloadPath,
|
||||
getApiBaseUrl: () => stats!.ensureStatsServerStarted(),
|
||||
getToggleKey: () => input.getResolvedConfig().stats.toggleKey,
|
||||
resolveBounds: () => input.overlay.getOverlayGeometry().getCurrentOverlayGeometry(),
|
||||
onVisibilityChanged: (visible) => {
|
||||
input.appState.statsOverlayVisible = visible;
|
||||
input.overlay.updateVisibleOverlayVisibility();
|
||||
},
|
||||
});
|
||||
},
|
||||
getMpvClient: () => input.appState.mpvClient as never,
|
||||
shouldAutoConnectMpv: () => !stats?.isStatsStartupInProgress(),
|
||||
seedTrackerFromCurrentMedia: () => {
|
||||
void input.dictionarySupport.seedImmersionMediaFromCurrentMedia();
|
||||
},
|
||||
logInfo: (message) => input.logger.info(message),
|
||||
logDebug: (message) => input.logger.debug(message),
|
||||
logWarn: (message, details) => input.logger.warn(message, details),
|
||||
};
|
||||
const createImmersionTrackerStartup = createImmersionTrackerStartupHandler(immersionMainDeps);
|
||||
|
||||
stats = createStatsRuntime({
|
||||
statsDaemonStatePath: input.statsDaemonStatePath,
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
getImmersionTracker: () => input.appState.immersionTracker,
|
||||
ensureImmersionTrackerStartedCore: () => {
|
||||
createImmersionTrackerStartup();
|
||||
},
|
||||
ensureVocabularyCleanupTokenizerReady: async () => {
|
||||
await input.createMecabTokenizerAndCheck();
|
||||
},
|
||||
startStatsServer: (port) =>
|
||||
startStatsServerCore({
|
||||
port,
|
||||
staticDir: input.statsDistPath,
|
||||
tracker: input.appState.immersionTracker as ImmersionTrackerService,
|
||||
knownWordCachePath: path.join(input.userDataPath, 'known-words-cache.json'),
|
||||
mpvSocketPath: input.appState.mpvSocketPath,
|
||||
ankiConnectConfig: input.getResolvedConfig().ankiConnect,
|
||||
resolveAnkiNoteId: (noteId: number) =>
|
||||
input.appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
||||
addYomitanNote: (word: string) => input.addYomitanNote(word),
|
||||
}),
|
||||
openExternal: (url) => input.openExternal(url),
|
||||
exitAppWithCode: (code) => {
|
||||
process.exitCode = code;
|
||||
input.requestAppQuit();
|
||||
},
|
||||
destroyStatsWindow: () => {
|
||||
input.destroyStatsWindow();
|
||||
},
|
||||
logInfo: (message) => input.logger.info(message),
|
||||
logWarn: (message, error) => input.logger.warn(message, error),
|
||||
logError: (message, error) => input.logger.error(message, error),
|
||||
});
|
||||
|
||||
return {
|
||||
stats,
|
||||
immersion: immersionMainDeps as AppReadyImmersionInput,
|
||||
recordTrackedCardsMined: (count, noteIds) => {
|
||||
stats.ensureImmersionTrackerStarted();
|
||||
input.appState.immersionTracker?.recordCardsMined?.(count, noteIds);
|
||||
},
|
||||
};
|
||||
}
|
||||
451
src/main/subtitle-dictionary-runtime.ts
Normal file
451
src/main/subtitle-dictionary-runtime.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { SubtitleCue } from '../core/services/subtitle-cue-parser';
|
||||
import type { AnilistMediaGuess } from '../core/services/anilist/anilist-updater';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type {
|
||||
FrequencyDictionaryLookup,
|
||||
ResolvedConfig,
|
||||
SubtitleData,
|
||||
SubtitlePosition,
|
||||
} from '../types';
|
||||
import {
|
||||
deleteYomitanDictionaryByTitle,
|
||||
getYomitanDictionaryInfo,
|
||||
importYomitanDictionaryFromZip,
|
||||
upsertYomitanDictionarySettings,
|
||||
clearYomitanParserCachesForWindow,
|
||||
} from '../core/services';
|
||||
import type { YomitanParserRuntimeDeps } from './yomitan-runtime';
|
||||
import {
|
||||
createDictionarySupportRuntime,
|
||||
type DictionarySupportRuntime,
|
||||
} from './dictionary-support-runtime';
|
||||
import {
|
||||
createDictionarySupportRuntimeInput,
|
||||
type DictionarySupportRuntimeInputBuilderInput,
|
||||
} from './dictionary-support-runtime-input';
|
||||
import {
|
||||
createSubtitleRuntime,
|
||||
type SubtitleRuntime,
|
||||
type SubtitleRuntimeInput,
|
||||
} from './subtitle-runtime';
|
||||
import type { JlptLookup } from './jlpt-runtime';
|
||||
import { formatSkippedYomitanWriteAction } from './runtime/yomitan-read-only-log';
|
||||
|
||||
type BrowserWindowLike = {
|
||||
isDestroyed: () => boolean;
|
||||
webContents: {
|
||||
send: (channel: string, payload?: unknown) => void;
|
||||
};
|
||||
};
|
||||
|
||||
type ImmersionTrackerLike = {
|
||||
handleMediaChange: (path: string, title: string | null) => void;
|
||||
} | null;
|
||||
|
||||
type MpvClientLike = {
|
||||
connected?: boolean;
|
||||
currentSubStart?: number | null;
|
||||
currentSubEnd?: number | null;
|
||||
currentTimePos?: number | null;
|
||||
currentVideoPath?: string | null;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
|
||||
type OverlayUiLike = {
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
} | null;
|
||||
|
||||
type OverlayManagerLike = {
|
||||
broadcastToOverlayWindows: (channel: string, payload?: unknown) => void;
|
||||
getMainWindow: () => BrowserWindowLike | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
};
|
||||
|
||||
type StartupOsdSequencerLike = NonNullable<
|
||||
DictionarySupportRuntimeInputBuilderInput['startup']['startupOsdSequencer']
|
||||
>;
|
||||
|
||||
export interface SubtitleDictionaryRuntimeInput {
|
||||
env: {
|
||||
platform: NodeJS.Platform;
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
userDataPath: string;
|
||||
appUserDataPath: string;
|
||||
homeDir: string;
|
||||
appDataDir?: string;
|
||||
cwd: string;
|
||||
configDir: string;
|
||||
defaultImmersionDbPath: string;
|
||||
};
|
||||
appState: {
|
||||
currentMediaPath: string | null;
|
||||
currentMediaTitle: string | null;
|
||||
currentSubText: string;
|
||||
currentSubAssText: string;
|
||||
mpvClient: MpvClientLike;
|
||||
subtitlePosition: SubtitlePosition | null;
|
||||
pendingSubtitlePosition: SubtitlePosition | null;
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
activeParsedSubtitleCues: SubtitleCue[];
|
||||
activeParsedSubtitleSource: string | null;
|
||||
immersionTracker: ImmersionTrackerLike;
|
||||
jlptLevelLookup: JlptLookup;
|
||||
frequencyRankLookup: FrequencyDictionaryLookup;
|
||||
yomitanParserWindow: BrowserWindowLike | null;
|
||||
};
|
||||
config: {
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
};
|
||||
services: {
|
||||
subtitleWsService: SubtitleRuntimeInput['subtitleWsService'];
|
||||
annotationSubtitleWsService: SubtitleRuntimeInput['annotationSubtitleWsService'];
|
||||
overlayManager: OverlayManagerLike;
|
||||
startupOsdSequencer: StartupOsdSequencerLike;
|
||||
};
|
||||
logging: {
|
||||
debug: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
subtitle: {
|
||||
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
|
||||
createSubtitlePrefetchService: SubtitleRuntimeInput['createSubtitlePrefetchService'];
|
||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||
clearSchedule: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
};
|
||||
overlay: {
|
||||
getOverlayUi: () => OverlayUiLike;
|
||||
showMpvOsd: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||
};
|
||||
playback: {
|
||||
isRemoteMediaPath: (mediaPath: string) => boolean;
|
||||
isYoutubePlaybackActive: (mediaPath: string | null, videoPath: string | null) => boolean;
|
||||
waitForYomitanMutationReady: (mediaKey: string | null) => Promise<void>;
|
||||
};
|
||||
anilist: {
|
||||
guessAnilistMediaInfo: (
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
) => Promise<AnilistMediaGuess | null>;
|
||||
};
|
||||
yomitan: {
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
getYomitanDictionaryInfo: () => Promise<Array<{ title: string; revision?: string | number }>>;
|
||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
||||
upsertYomitanDictionarySettings: (
|
||||
dictionaryTitle: string,
|
||||
profileScope: ResolvedConfig['anilist']['characterDictionary']['profileScope'],
|
||||
) => Promise<boolean>;
|
||||
hasParserWindow: () => boolean;
|
||||
clearParserCaches: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubtitleDictionaryRuntime {
|
||||
subtitle: SubtitleRuntime;
|
||||
dictionarySupport: DictionarySupportRuntime;
|
||||
}
|
||||
|
||||
export interface SubtitleDictionaryRuntimeCoordinatorInput {
|
||||
env: SubtitleDictionaryRuntimeInput['env'];
|
||||
appState: SubtitleDictionaryRuntimeInput['appState'];
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
services: SubtitleDictionaryRuntimeInput['services'];
|
||||
logging: {
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
overlay: SubtitleDictionaryRuntimeInput['overlay'];
|
||||
playback: SubtitleDictionaryRuntimeInput['playback'];
|
||||
anilist: SubtitleDictionaryRuntimeInput['anilist'];
|
||||
subtitle: {
|
||||
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
|
||||
createSubtitlePrefetchService: SubtitleRuntimeInput['createSubtitlePrefetchService'];
|
||||
};
|
||||
yomitan: {
|
||||
isCharacterDictionaryEnabled: () => boolean;
|
||||
isExternalReadOnlyMode: () => boolean;
|
||||
logSkippedWrite: (message: string) => void;
|
||||
ensureYomitanExtensionLoaded: () => Promise<unknown>;
|
||||
getParserRuntimeDeps: () => YomitanParserRuntimeDeps;
|
||||
};
|
||||
}
|
||||
|
||||
export function createSubtitleDictionaryRuntime(
|
||||
input: SubtitleDictionaryRuntimeInput,
|
||||
): SubtitleDictionaryRuntime {
|
||||
const subtitlePositionsDir = path.join(input.env.configDir, 'subtitle-positions');
|
||||
|
||||
const subtitle = createSubtitleRuntime({
|
||||
getResolvedConfig: () => input.config.getResolvedConfig(),
|
||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
||||
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
|
||||
getCurrentSubText: () => input.appState.currentSubText,
|
||||
getCurrentSubAssText: () => input.appState.currentSubAssText,
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
subtitleWsService: input.services.subtitleWsService,
|
||||
annotationSubtitleWsService: input.services.annotationSubtitleWsService,
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
input.services.overlayManager.broadcastToOverlayWindows(channel, payload),
|
||||
subtitlePositionsDir,
|
||||
setSubtitlePosition: (position) => {
|
||||
input.appState.subtitlePosition = position;
|
||||
},
|
||||
setPendingSubtitlePosition: (position) => {
|
||||
input.appState.pendingSubtitlePosition = position;
|
||||
},
|
||||
clearPendingSubtitlePosition: () => {
|
||||
input.appState.pendingSubtitlePosition = null;
|
||||
},
|
||||
setCurrentSubtitleData: (payload) => {
|
||||
input.appState.currentSubtitleData = payload;
|
||||
},
|
||||
setActiveParsedSubtitleState: (cues, sourceKey) => {
|
||||
input.appState.activeParsedSubtitleCues = cues;
|
||||
input.appState.activeParsedSubtitleSource = sourceKey;
|
||||
},
|
||||
parseSubtitleCues: (content, filename) => input.subtitle.parseSubtitleCues(content, filename),
|
||||
createSubtitlePrefetchService: (deps) => input.subtitle.createSubtitlePrefetchService(deps),
|
||||
schedule: (callback, delayMs) => input.subtitle.schedule(callback, delayMs),
|
||||
clearSchedule: (timer) => input.subtitle.clearSchedule(timer),
|
||||
logDebug: (message) => input.logging.debug(message),
|
||||
logInfo: (message) => input.logging.info(message),
|
||||
logWarn: (message) => input.logging.warn(message),
|
||||
});
|
||||
|
||||
const dictionarySupport = createDictionarySupportRuntime<OverlayHostedModal>(
|
||||
createDictionarySupportRuntimeInput({
|
||||
env: {
|
||||
platform: input.env.platform,
|
||||
dirname: input.env.dirname,
|
||||
appPath: input.env.appPath,
|
||||
resourcesPath: input.env.resourcesPath,
|
||||
userDataPath: input.env.userDataPath,
|
||||
appUserDataPath: input.env.appUserDataPath,
|
||||
homeDir: input.env.homeDir,
|
||||
appDataDir: input.env.appDataDir,
|
||||
cwd: input.env.cwd,
|
||||
subtitlePositionsDir,
|
||||
defaultImmersionDbPath: input.env.defaultImmersionDbPath,
|
||||
},
|
||||
config: {
|
||||
getResolvedConfig: () => input.config.getResolvedConfig(),
|
||||
},
|
||||
dictionaryState: {
|
||||
setJlptLevelLookup: (lookup) => {
|
||||
input.appState.jlptLevelLookup = lookup;
|
||||
},
|
||||
setFrequencyRankLookup: (lookup) => {
|
||||
input.appState.frequencyRankLookup = lookup;
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
info: (message) => input.logging.info(message),
|
||||
debug: (message) => input.logging.debug(message),
|
||||
warn: (message) => input.logging.warn(message),
|
||||
error: (message, ...args) => input.logging.error(message, ...args),
|
||||
},
|
||||
media: {
|
||||
isRemoteMediaPath: (mediaPath) => input.playback.isRemoteMediaPath(mediaPath),
|
||||
getCurrentMediaPath: () => input.appState.currentMediaPath,
|
||||
setCurrentMediaPath: (mediaPath) => {
|
||||
input.appState.currentMediaPath = mediaPath;
|
||||
},
|
||||
getCurrentMediaTitle: () => input.appState.currentMediaTitle,
|
||||
setCurrentMediaTitle: (title) => {
|
||||
input.appState.currentMediaTitle = title;
|
||||
},
|
||||
getPendingSubtitlePosition: () => input.appState.pendingSubtitlePosition,
|
||||
clearPendingSubtitlePosition: () => {
|
||||
input.appState.pendingSubtitlePosition = null;
|
||||
},
|
||||
setSubtitlePosition: (position) => {
|
||||
input.appState.subtitlePosition = position;
|
||||
},
|
||||
},
|
||||
subtitle: {
|
||||
loadSubtitlePosition: () => subtitle.loadSubtitlePosition(),
|
||||
invalidateTokenizationCache: () => {
|
||||
subtitle.invalidateTokenizationCache();
|
||||
},
|
||||
refreshSubtitlePrefetchFromActiveTrack: () => {
|
||||
subtitle.refreshSubtitlePrefetchFromActiveTrack();
|
||||
},
|
||||
refreshCurrentSubtitle: (text) => {
|
||||
subtitle.refreshCurrentSubtitle(text);
|
||||
},
|
||||
getCurrentSubtitleText: () => input.appState.currentSubText,
|
||||
},
|
||||
overlay: {
|
||||
broadcastSubtitlePosition: (position) => {
|
||||
input.services.overlayManager.broadcastToOverlayWindows('subtitle:position', position);
|
||||
},
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
input.services.overlayManager.broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
getMainWindow: () => input.services.overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => input.services.overlayManager.getVisibleOverlayVisible(),
|
||||
setVisibleOverlayVisible: (visible) => {
|
||||
input.overlay.getOverlayUi()?.setVisibleOverlayVisible(visible);
|
||||
},
|
||||
getRestoreVisibleOverlayOnModalClose: () =>
|
||||
input.overlay.getOverlayUi()?.getRestoreVisibleOverlayOnModalClose() ??
|
||||
new Set<OverlayHostedModal>(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
input.overlay
|
||||
.getOverlayUi()
|
||||
?.sendToActiveOverlayWindow(channel, payload, runtimeOptions) ?? false,
|
||||
},
|
||||
tracker: {
|
||||
getTracker: () => input.appState.immersionTracker,
|
||||
getMpvClient: () => input.appState.mpvClient,
|
||||
},
|
||||
anilist: {
|
||||
guessAnilistMediaInfo: (mediaPath, mediaTitle) =>
|
||||
input.anilist.guessAnilistMediaInfo(mediaPath, mediaTitle),
|
||||
},
|
||||
yomitan: {
|
||||
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
|
||||
getYomitanDictionaryInfo: () => input.yomitan.getYomitanDictionaryInfo(),
|
||||
importYomitanDictionary: (zipPath) => input.yomitan.importYomitanDictionary(zipPath),
|
||||
deleteYomitanDictionary: (dictionaryTitle) =>
|
||||
input.yomitan.deleteYomitanDictionary(dictionaryTitle),
|
||||
upsertYomitanDictionarySettings: (dictionaryTitle, profileScope) =>
|
||||
input.yomitan.upsertYomitanDictionarySettings(dictionaryTitle, profileScope),
|
||||
hasParserWindow: () => input.yomitan.hasParserWindow(),
|
||||
clearParserCaches: () => input.yomitan.clearParserCaches(),
|
||||
},
|
||||
startup: {
|
||||
getNotificationType: () =>
|
||||
input.config.getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||
showMpvOsd: (message) => input.overlay.showMpvOsd(message),
|
||||
showDesktopNotification: (title, options) =>
|
||||
input.overlay.showDesktopNotification(title, options),
|
||||
startupOsdSequencer: input.services.startupOsdSequencer,
|
||||
},
|
||||
playback: {
|
||||
isYoutubePlaybackActiveNow: () =>
|
||||
input.playback.isYoutubePlaybackActive(
|
||||
input.appState.currentMediaPath,
|
||||
input.appState.mpvClient?.currentVideoPath ?? null,
|
||||
),
|
||||
waitForYomitanMutationReady: () =>
|
||||
input.playback.waitForYomitanMutationReady(
|
||||
input.appState.currentMediaPath?.trim() ||
|
||||
input.appState.mpvClient?.currentVideoPath?.trim() ||
|
||||
null,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
subtitle,
|
||||
dictionarySupport,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSubtitleDictionaryRuntimeCoordinator(
|
||||
input: SubtitleDictionaryRuntimeCoordinatorInput,
|
||||
): SubtitleDictionaryRuntime {
|
||||
return createSubtitleDictionaryRuntime({
|
||||
env: input.env,
|
||||
appState: input.appState,
|
||||
config: {
|
||||
getResolvedConfig: () => input.getResolvedConfig(),
|
||||
},
|
||||
services: input.services,
|
||||
logging: input.logging,
|
||||
subtitle: {
|
||||
parseSubtitleCues: (content, filename) => input.subtitle.parseSubtitleCues(content, filename),
|
||||
createSubtitlePrefetchService: (deps) => input.subtitle.createSubtitlePrefetchService(deps),
|
||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
clearSchedule: (timer) => clearTimeout(timer),
|
||||
},
|
||||
overlay: input.overlay,
|
||||
playback: input.playback,
|
||||
anilist: input.anilist,
|
||||
yomitan: {
|
||||
isCharacterDictionaryEnabled: () => input.yomitan.isCharacterDictionaryEnabled(),
|
||||
getYomitanDictionaryInfo: async () => {
|
||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
||||
return await getYomitanDictionaryInfo(input.yomitan.getParserRuntimeDeps(), {
|
||||
error: (message, ...args) => input.logging.error(message, ...args),
|
||||
info: (message, ...args) => input.logging.info(message, ...args),
|
||||
});
|
||||
},
|
||||
importYomitanDictionary: async (zipPath) => {
|
||||
if (input.yomitan.isExternalReadOnlyMode()) {
|
||||
input.yomitan.logSkippedWrite(
|
||||
formatSkippedYomitanWriteAction('importYomitanDictionary', zipPath),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
||||
return await importYomitanDictionaryFromZip(zipPath, input.yomitan.getParserRuntimeDeps(), {
|
||||
error: (message, ...args) => input.logging.error(message, ...args),
|
||||
info: (message, ...args) => input.logging.info(message, ...args),
|
||||
});
|
||||
},
|
||||
deleteYomitanDictionary: async (dictionaryTitle) => {
|
||||
if (input.yomitan.isExternalReadOnlyMode()) {
|
||||
input.yomitan.logSkippedWrite(
|
||||
formatSkippedYomitanWriteAction('deleteYomitanDictionary', dictionaryTitle),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
||||
return await deleteYomitanDictionaryByTitle(
|
||||
dictionaryTitle,
|
||||
input.yomitan.getParserRuntimeDeps(),
|
||||
{
|
||||
error: (message, ...args) => input.logging.error(message, ...args),
|
||||
info: (message, ...args) => input.logging.info(message, ...args),
|
||||
},
|
||||
);
|
||||
},
|
||||
upsertYomitanDictionarySettings: async (dictionaryTitle, profileScope) => {
|
||||
if (input.yomitan.isExternalReadOnlyMode()) {
|
||||
input.yomitan.logSkippedWrite(
|
||||
formatSkippedYomitanWriteAction('upsertYomitanDictionarySettings', dictionaryTitle),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await input.yomitan.ensureYomitanExtensionLoaded();
|
||||
return await upsertYomitanDictionarySettings(
|
||||
dictionaryTitle,
|
||||
profileScope,
|
||||
input.yomitan.getParserRuntimeDeps(),
|
||||
{
|
||||
error: (message, ...args) => input.logging.error(message, ...args),
|
||||
info: (message, ...args) => input.logging.info(message, ...args),
|
||||
},
|
||||
);
|
||||
},
|
||||
hasParserWindow: () => Boolean(input.appState.yomitanParserWindow),
|
||||
clearParserCaches: () => {
|
||||
if (input.appState.yomitanParserWindow) {
|
||||
clearYomitanParserCachesForWindow(input.appState.yomitanParserWindow as never);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
131
src/main/subtitle-runtime-sources.ts
Normal file
131
src/main/subtitle-runtime-sources.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { codecToExtension } from '../subsync/utils';
|
||||
import { resolveSubtitleSourcePath } from './runtime/subtitle-prefetch-source';
|
||||
|
||||
export type MpvSubtitleTrackLike = {
|
||||
type?: unknown;
|
||||
id?: unknown;
|
||||
codec?: unknown;
|
||||
external?: unknown;
|
||||
'ff-index'?: unknown;
|
||||
'external-filename'?: unknown;
|
||||
};
|
||||
|
||||
const DEFAULT_SUBTITLE_SOURCE_FETCH_TIMEOUT_MS = 4000;
|
||||
|
||||
function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildFfmpegSubtitleExtractionArgs(
|
||||
videoPath: string,
|
||||
ffIndex: number,
|
||||
outputPath: string,
|
||||
): string[] {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-nostdin',
|
||||
'-y',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-an',
|
||||
'-vn',
|
||||
'-i',
|
||||
videoPath,
|
||||
'-map',
|
||||
`0:${ffIndex}`,
|
||||
'-f',
|
||||
path.extname(outputPath).slice(1),
|
||||
outputPath,
|
||||
];
|
||||
}
|
||||
|
||||
export function createSubtitleSourceLoader(options?: {
|
||||
fetchImpl?: typeof fetch;
|
||||
subtitleSourceFetchTimeoutMs?: number;
|
||||
}): (source: string) => Promise<string> {
|
||||
const fetchImpl = options?.fetchImpl ?? fetch;
|
||||
const timeoutMs =
|
||||
options?.subtitleSourceFetchTimeoutMs ?? DEFAULT_SUBTITLE_SOURCE_FETCH_TIMEOUT_MS;
|
||||
|
||||
return async (source: string): Promise<string> => {
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetchImpl(source, { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download subtitle source (${response.status})`);
|
||||
}
|
||||
return await response.text();
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = resolveSubtitleSourcePath(source);
|
||||
return await fs.promises.readFile(filePath, 'utf8');
|
||||
};
|
||||
}
|
||||
|
||||
export function createExtractInternalSubtitleTrackToTempFileHandler() {
|
||||
return async (
|
||||
ffmpegPath: string,
|
||||
videoPath: string,
|
||||
track: MpvSubtitleTrackLike,
|
||||
): Promise<{ path: string; cleanup: () => Promise<void> } | null> => {
|
||||
const ffIndex = parseTrackId(track['ff-index']);
|
||||
const codec = typeof track.codec === 'string' ? track.codec : null;
|
||||
const extension = codecToExtension(codec ?? undefined);
|
||||
if (ffIndex === null || extension === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-'));
|
||||
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
ffmpegPath,
|
||||
buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath),
|
||||
);
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
path: outputPath,
|
||||
cleanup: async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user