diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 00000000..7456fc2c --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,406 @@ +name: Prerelease + +on: + push: + tags: + - 'v*-beta.*' + - 'v*-rc.*' + +concurrency: + group: prerelease-${{ github.ref }} + cancel-in-progress: false + +jobs: + quality-gate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + stats/node_modules + vendor/subminer-yomitan/node_modules + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: Install dependencies + run: | + bun install --frozen-lockfile + cd stats && bun install --frozen-lockfile + + - name: Lint stats (formatting) + run: bun run lint:stats + + - name: Build (TypeScript check) + run: bun run typecheck + + - name: Test suite (source) + run: bun run test:fast + + - name: Environment suite + run: bun run test:env + + - name: Coverage suite (maintained source lane) + run: bun run test:coverage:src + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-test-src + path: coverage/test-src/lcov.info + if-no-files-found: error + + - name: Launcher smoke suite (source) + run: bun run test:launcher:smoke:src + + - name: Upload launcher smoke artifacts (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: launcher-smoke + path: .tmp/launcher-smoke/** + if-no-files-found: ignore + + - name: Build (bundle) + run: bun run build + + - name: Immersion SQLite verification + run: bun run test:immersion:sqlite:dist + + - name: Dist smoke suite + run: bun run test:smoke:dist + + build-linux: + needs: [quality-gate] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + stats/node_modules + vendor/texthooker-ui/node_modules + vendor/subminer-yomitan/node_modules + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: Install dependencies + run: | + bun install --frozen-lockfile + cd stats && bun install --frozen-lockfile + + - name: Build texthooker-ui + run: | + cd vendor/texthooker-ui + bun install + bun run build + + - name: Build AppImage + run: bun run build:appimage + + - name: Build unversioned AppImage + run: | + shopt -s nullglob + appimages=(release/SubMiner-*.AppImage) + if [ "${#appimages[@]}" -eq 0 ]; then + echo "No versioned AppImage found to create unversioned artifact." + ls -la release + exit 1 + fi + cp "${appimages[0]}" release/SubMiner.AppImage + + - name: Upload AppImage artifact + uses: actions/upload-artifact@v4 + with: + name: appimage + path: release/*.AppImage + if-no-files-found: error + + build-macos: + needs: [quality-gate] + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + stats/node_modules + vendor/texthooker-ui/node_modules + vendor/subminer-yomitan/node_modules + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: Validate macOS signing/notarization secrets + run: | + missing=0 + for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!name}" ]; then + echo "Missing required secret: $name" + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + echo "Set all required macOS signing/notarization secrets and rerun." + exit 1 + fi + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Install dependencies + run: | + bun install --frozen-lockfile + cd stats && bun install --frozen-lockfile + + - name: Build texthooker-ui + run: | + cd vendor/texthooker-ui + bun install + bun run build + + - name: Build signed + notarized macOS artifacts + run: bun run build:mac + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Upload macOS artifacts + uses: actions/upload-artifact@v4 + with: + name: macos + path: | + release/*.dmg + release/*.zip + if-no-files-found: error + + build-windows: + needs: [quality-gate] + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + stats/node_modules + vendor/texthooker-ui/node_modules + vendor/subminer-yomitan/node_modules + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: Install dependencies + run: | + bun install --frozen-lockfile + cd stats && bun install --frozen-lockfile + + - name: Build texthooker-ui + shell: powershell + run: | + Set-Location vendor/texthooker-ui + bun install + bun run build + + - name: Build unsigned Windows artifacts + run: bun run build:win:unsigned + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: windows + path: | + release/*.exe + release/*.zip + if-no-files-found: error + + release: + needs: [build-linux, build-macos, build-windows] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download AppImage + uses: actions/download-artifact@v4 + with: + name: appimage + path: release + + - name: Download macOS artifacts + uses: actions/download-artifact@v4 + with: + name: macos + path: release + + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: windows + path: release + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + ~/.bun/install/cache + node_modules + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build Bun subminer wrapper + run: make build-launcher + + - name: Verify Bun subminer wrapper + run: dist/launcher/subminer --help >/dev/null + + - name: Enforce generated launcher workflow + run: bash scripts/verify-generated-launcher.sh + + - name: Verify generated config examples + run: bun run verify:config-example + + - name: Package optional assets bundle + run: | + tar -czf "release/subminer-assets.tar.gz" \ + config.example.jsonc \ + plugin/subminer \ + plugin/subminer.conf \ + assets/themes/subminer.rasi + + - name: Generate checksums + run: | + shopt -s nullglob + files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz dist/launcher/subminer) + if [ "${#files[@]}" -eq 0 ]; then + echo "No release artifacts found for checksum generation." + exit 1 + fi + : > release/SHA256SUMS.txt + for file in "${files[@]}"; do + printf '%s %s\n' \ + "$(sha256sum "$file" | awk '{print $1}')" \ + "${file##*/}" >> release/SHA256SUMS.txt + done + + - name: Get version from tag + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + + - name: Generate prerelease notes from pending fragments + run: bun run changelog:prerelease-notes --version "${{ steps.version.outputs.VERSION }}" + + - name: Publish Prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + shopt -s nullglob + artifacts=( + release/*.AppImage + release/*.dmg + release/*.exe + release/*.zip + release/*.tar.gz + release/SHA256SUMS.txt + dist/launcher/subminer + ) + + if [ "${#artifacts[@]}" -eq 0 ]; then + echo "No release artifacts found for upload." + exit 1 + fi + + if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then + gh release edit "${{ steps.version.outputs.VERSION }}" \ + --draft \ + --prerelease \ + --title "${{ steps.version.outputs.VERSION }}" \ + --notes-file release/prerelease-notes.md + else + gh release create "${{ steps.version.outputs.VERSION }}" \ + --draft \ + --latest=false \ + --prerelease \ + --title "${{ steps.version.outputs.VERSION }}" \ + --notes-file release/prerelease-notes.md + fi + + for asset in "${artifacts[@]}"; do + gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber + done + + gh release edit "${{ steps.version.outputs.VERSION }}" \ + --draft=false \ + --prerelease \ + --title "${{ steps.version.outputs.VERSION }}" \ + --notes-file release/prerelease-notes.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5c29709..4bcbc749 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,8 @@ on: push: tags: - 'v*' + - '!v*-beta.*' + - '!v*-rc.*' concurrency: group: release-${{ github.ref }} @@ -338,7 +340,12 @@ jobs: echo "No release artifacts found for checksum generation." exit 1 fi - sha256sum "${files[@]}" > release/SHA256SUMS.txt + : > release/SHA256SUMS.txt + for file in "${files[@]}"; do + printf '%s %s\n' \ + "$(sha256sum "$file" | awk '{print $1}')" \ + "${file##*/}" >> release/SHA256SUMS.txt + done - name: Get version from tag id: version diff --git a/backlog/tasks/task-285 - Investigate-inconsistent-mpv-y-t-overlay-toggle-after-menu-toggle.md b/backlog/tasks/task-285 - Investigate-inconsistent-mpv-y-t-overlay-toggle-after-menu-toggle.md new file mode 100644 index 00000000..6b344005 --- /dev/null +++ b/backlog/tasks/task-285 - Investigate-inconsistent-mpv-y-t-overlay-toggle-after-menu-toggle.md @@ -0,0 +1,54 @@ +--- +id: TASK-285 +title: Investigate inconsistent mpv y-t overlay toggle after menu toggle +status: To Do +assignee: [] +created_date: '2026-04-07 22:55' +updated_date: '2026-04-07 22:55' +labels: + - bug + - overlay + - keyboard + - mpv +dependencies: [] +references: + - plugin/subminer/process.lua + - plugin/subminer/ui.lua + - src/renderer/handlers/keyboard.ts + - src/main/runtime/autoplay-ready-gate.ts + - src/core/services/overlay-window-input.ts + - backlog/tasks/task-248 - Fix-macOS-visible-overlay-toggle-getting-immediately-restored.md +priority: high +--- + +## Description + + +User report: toggling the visible overlay with mpv `y-t` is inconsistent. After manually toggling through the `y-y` menu, `y-t` may allow one hide, but after toggling back on it can stop hiding the overlay again, forcing the user back into the menu path. + +Initial assessment: + +- no active backlog item currently tracks this exact report +- nearest prior work is `TASK-248`, which fixed a macOS-specific visible-overlay restore bug and is marked done +- current targeted regressions for the old fix surface pass, including plugin ready-signal suppression, focused-overlay `y-t` proxy dispatch, autoplay-ready gate deduplication, and blur-path restacking guards + +This should be treated as a fresh investigation unless reproduction proves it is the same closed macOS issue resurfacing on the current build. + + +## Acceptance Criteria + +- [ ] #1 Reproduce the reported `y-t` / `y-y` inconsistency on the affected platform and identify the exact event sequence +- [ ] #2 Determine whether the failure is in mpv plugin command dispatch, focused-overlay key forwarding, or main-process visible-overlay state transitions +- [ ] #3 Fix the inconsistency so repeated hide/show/hide cycles work from `y-t` without requiring menu recovery +- [ ] #4 Add regression coverage for the reproduced failing sequence +- [ ] #5 Record whether this is a regression of `TASK-248` or a distinct bug + + +## Implementation Plan + + +1. Reproduce the report with platform/build details and capture whether the failing `y-t` press originates in raw mpv or the focused overlay y-chord proxy path. +2. Trace visible-overlay state mutations across plugin toggle commands, autoplay-ready callbacks, and main-process visibility/window blur handling. +3. Patch the narrowest failing path and add regression coverage for the exact hide/show/hide sequence. +4. Re-run targeted plugin, overlay visibility, overlay window, and renderer keyboard suites before broader verification. + diff --git a/backlog/tasks/task-286 - Assess-and-address-PR-49-CodeRabbit-review-follow-ups.md b/backlog/tasks/task-286 - Assess-and-address-PR-49-CodeRabbit-review-follow-ups.md new file mode 100644 index 00000000..6f146ee1 --- /dev/null +++ b/backlog/tasks/task-286 - Assess-and-address-PR-49-CodeRabbit-review-follow-ups.md @@ -0,0 +1,63 @@ +--- +id: TASK-286 +title: 'Assess and address PR #49 CodeRabbit review follow-ups' +status: Done +assignee: + - codex +created_date: '2026-04-11 18:55' +updated_date: '2026-04-11 22:40' +labels: + - bug + - code-review + - windows + - overlay +dependencies: [] +references: + - src/main/runtime/config-hot-reload-handlers.ts + - src/renderer/handlers/keyboard.ts + - src/renderer/handlers/mouse.ts + - vendor/subminer-yomitan +priority: high +--- + +## Description + + +Track the current PR #49 review round and resolve the actionable CodeRabbit findings on the Windows update branch. + +Focus areas include the renderer mouse interaction fix, config hot-reload keyboard state, and any other review items that still apply after verifying the current branch state. + + +## Acceptance Criteria + +- [x] #1 All actionable CodeRabbit comments on PR #49 are either fixed or shown to be obsolete with evidence. +- [x] #2 Regression tests are added or updated for any behavior change that could regress. +- [x] #3 The branch passes the repo's relevant verification checks for the touched areas. + + +## Implementation Plan + + +1. Pull the current unresolved CodeRabbit review threads for PR #49 and cluster them into still-actionable fixes versus obsolete/nit-only items. +2. For each still-actionable behavior bug, add or extend the narrowest failing test first in the touched suite before changing production code. +3. Implement the minimal fixes across the affected runtime, renderer, plugin, IPC, and Windows tracker files, keeping each change traceable to the review thread. +4. Run targeted verification for the touched areas, update task notes with assessment results, and capture which review comments were fixed versus assessed as obsolete or deferred nitpicks. + + +## Implementation Notes + + +Assessed PR #49 CodeRabbit threads. Fixed the real regressions in first-run CLI gating, IPC session-action validation, renderer controller-modal lifecycle notifications, async subtitle-sidebar toggle guarding, plugin config-dir resolution priority, prerelease artifact upload failure handling, immersion tracker lazy startup, win32 z-order error handling, and Windows socket-aware mpv matching. + +Review assessment: the overlay-shortcut lifecycle comment is obsolete for the current architecture because overlay-local shortcuts are intentionally handled through the local fallback path and the runtime only tracks configured-state/cleanup. Refactor-only nit comments for splitting `scripts/build-changelog.ts` and `src/core/services/session-bindings.ts` were left as follow-up quality work, not behavior bugs in this PR round. + +Verification: `bun test src/main/runtime/first-run-setup-service.test.ts src/core/services/session-bindings.test.ts src/core/services/app-ready.test.ts src/core/services/ipc.test.ts src/renderer/handlers/keyboard.test.ts src/main/overlay-runtime.test.ts src/window-trackers/mpv-socket-match.test.ts`, `bun test src/window-trackers/windows-tracker.test.ts`, `bun run typecheck`, `lua scripts/test-plugin-lua-compat.lua`. + + +## Final Summary + + +Assessed the current CodeRabbit round on PR #49 and addressed the still-valid behavior issues rather than blanket-applying every bot suggestion. The branch now treats the new session/stats CLI flags as explicit startup commands during first-run setup, validates the new session actions through IPC, points session-binding command diagnostics at the correct config field, keeps immersion tracker startup lazy until later runtime triggers, and notifies overlay modal lifecycle state when controller-select/debug are opened from local keyboard bindings. I also switched the subtitle-sidebar IPC callback to the async guarded path so promise rejections feed renderer recovery instead of being dropped. + +On the Windows/plugin side, the mpv plugin now prefers config-file matches before falling back to an existing config directory, prerelease workflow uploads fail if expected Linux/macOS artifacts are missing, the Win32 z-order bind path now validates the `GetWindowLongW` call for the window above mpv, and the Windows tracker now passes the target socket path into native polling and filters mpv instances by command line so multiple sockets can be distinguished on Windows. Added/updated regression coverage for first-run gating, IPC validation, session-binding diagnostics, controller modal lifecycle notifications, modal ready-listener dispatch, and socket-path matching. Verification run: `bun run typecheck`, the targeted Bun test suites for the touched areas, `bun test src/window-trackers/windows-tracker.test.ts`, and `lua scripts/test-plugin-lua-compat.lua`. + diff --git a/backlog/tasks/task-286.1 - Assess-and-address-PR-49-subsequent-CodeRabbit-review-round.md b/backlog/tasks/task-286.1 - Assess-and-address-PR-49-subsequent-CodeRabbit-review-round.md new file mode 100644 index 00000000..9b86647f --- /dev/null +++ b/backlog/tasks/task-286.1 - Assess-and-address-PR-49-subsequent-CodeRabbit-review-round.md @@ -0,0 +1,61 @@ +--- +id: TASK-286.1 +title: 'Assess and address PR #49 subsequent CodeRabbit review round' +status: Done +assignee: [] +created_date: '2026-04-11 23:14' +updated_date: '2026-04-11 23:16' +labels: + - bug + - code-review + - windows + - release +dependencies: [] +references: + - .github/workflows/prerelease.yml + - src/window-trackers/mpv-socket-match.ts + - src/window-trackers/win32.ts + - src/core/services/overlay-shortcut.ts +parent_task_id: TASK-286 +priority: high +--- + +## Description + + +Track the next unresolved CodeRabbit review threads on PR #49 after commit 9ce5de2f and resolve the still-valid follow-up issues without reopening already-assessed stale comments. + + +## Acceptance Criteria + +- [x] #1 All still-actionable CodeRabbit comments in the latest PR #49 round are fixed or explicitly shown stale with evidence. +- [x] #2 Regression coverage is added or updated for any behavior-sensitive fix in workflow or Windows socket matching. +- [x] #3 Relevant verification passes for the touched workflow, tracker, and shared matcher changes. + + +## Implementation Plan + + +1. Verify the five unresolved CodeRabbit threads against current branch state and separate still-valid bugs from stale comments. +2. Add or extend the narrowest failing tests for exact socket-path matching and prerelease workflow invariants before changing production code. +3. Implement minimal fixes in the prerelease workflow and Windows socket matching/cache path, leaving stale comments documented with evidence instead of forcing no-op changes. +4. Run targeted verification, then record the fixed-vs-stale assessment and close the subtask. + + +## Implementation Notes + + +Assessed five unresolved PR #49 threads after 9ce5de2f. Fixed prerelease workflow cache keys to include `runner.arch`, changed prerelease publishing to validate artifacts before release creation/edit and only undraft after uploads complete, tightened Windows socket matching to require exact argument boundaries, and stopped memoizing null command-line lookup misses in the Win32 cache path. + +Stale assessment: the `src/core/services/overlay-shortcut.ts` thread is still obsolete. Current code at `registerOverlayShortcuts()` returns `hasConfiguredOverlayShortcuts(shortcuts)`, not `false`, and the overlay-local handling remains intentionally driven by local fallback dispatch rather than global registration in this runtime path. + +Verification: `bun test src/prerelease-workflow.test.ts src/window-trackers/mpv-socket-match.test.ts`, `bun test src/window-trackers/windows-tracker.test.ts src/prerelease-workflow.test.ts src/window-trackers/mpv-socket-match.test.ts`, `bun run typecheck`. + + +## Final Summary + + +Handled the next CodeRabbit round on PR #49 by fixing the still-valid prerelease workflow and Windows socket-matching issues while documenting the stale overlay-shortcut comment instead of forcing a no-op code change. The prerelease workflow now scopes all dependency caches by `runner.arch`, validates the final artifact set before touching the GitHub release, creates/edits the prerelease as a draft during uploads, and only flips `--draft=false` after all assets succeed. On Windows, socket matching now requires an exact `--input-ipc-server` argument boundary so `subminer-1` no longer matches `subminer-10`, and transient PowerShell/CIM misses no longer get cached forever as null command lines. + +Regression coverage was added for the workflow invariants and exact socket matching. Verification passed with targeted prerelease workflow tests, Windows tracker tests, socket-matcher tests, and `bun run typecheck`. + diff --git a/backlog/tasks/task-286.2 - Assess-and-address-PR-49-next-CodeRabbit-review-round.md b/backlog/tasks/task-286.2 - Assess-and-address-PR-49-next-CodeRabbit-review-round.md new file mode 100644 index 00000000..20c7fd67 --- /dev/null +++ b/backlog/tasks/task-286.2 - Assess-and-address-PR-49-next-CodeRabbit-review-round.md @@ -0,0 +1,49 @@ +--- +id: TASK-286.2 +title: 'Assess and address PR #49 next CodeRabbit review round' +status: Done +assignee: [] +created_date: '2026-04-12 02:50' +updated_date: '2026-04-12 02:52' +labels: + - bug + - code-review + - release + - testing +dependencies: [] +references: + - .github/workflows/prerelease.yml + - src/prerelease-workflow.test.ts + - src/core/services/overlay-shortcut.ts +parent_task_id: TASK-286 +priority: high +--- + +## Description + + +Track the next unresolved CodeRabbit review threads on PR #49 after commit 62ad77dc and resolve the still-valid follow-up issues while documenting stale repeats. + + +## Acceptance Criteria + +- [x] #1 All still-actionable CodeRabbit comments in the latest PR #49 round are fixed or explicitly shown stale with evidence. +- [x] #2 Regression coverage is updated for any workflow or test changes made in this round. +- [x] #3 Relevant verification passes for the touched workflow and prerelease test changes. + + +## Implementation Notes + + +Assessed latest unresolved CodeRabbit round on PR #49. `src/core/services/overlay-shortcut.ts` comment is stale: `registerOverlayShortcuts()` returns `hasConfiguredOverlayShortcuts(shortcuts)`, so runtime registration is not hard-coded false. + +Added exact, line-ending-agnostic prerelease tag trigger assertions in `src/prerelease-workflow.test.ts` and a regression asserting `bun run test:env` sits in the prerelease quality gate before source coverage. + +Updated `.github/workflows/prerelease.yml` quality-gate to run `bun run test:env` after `bun run test:fast`. + + +## Final Summary + + +Assessed the latest CodeRabbit round for PR #49. Left the `overlay-shortcut.ts` thread open as stale with code evidence, tightened prerelease workflow trigger coverage, and added the missing `test:env` step to the prerelease quality gate. Verification: `bun test src/prerelease-workflow.test.ts`; `bun run typecheck`. + diff --git a/backlog/tasks/task-286.3 - Assess-and-address-PR-49-latest-CodeRabbit-review-round.md b/backlog/tasks/task-286.3 - Assess-and-address-PR-49-latest-CodeRabbit-review-round.md new file mode 100644 index 00000000..a15913a8 --- /dev/null +++ b/backlog/tasks/task-286.3 - Assess-and-address-PR-49-latest-CodeRabbit-review-round.md @@ -0,0 +1,48 @@ +--- +id: TASK-286.3 +title: 'Assess and address PR #49 latest CodeRabbit review round' +status: Done +assignee: [] +created_date: '2026-04-12 03:08' +updated_date: '2026-04-12 03:09' +labels: + - bug + - code-review + - testing +dependencies: [] +references: + - 'PR #49' + - .github/workflows/prerelease.yml + - src +parent_task_id: TASK-286 +priority: high +--- + +## Description + + +Track the newest unresolved CodeRabbit review threads on PR #49 after commit 942c1649, fix the still-valid issues, verify them, and push the branch update. + + +## Acceptance Criteria + +- [x] #1 All still-actionable CodeRabbit comments in the newest PR #49 round are fixed or explicitly identified stale with evidence. +- [x] #2 Regression coverage is added or updated for behavior touched in this round. +- [x] #3 Relevant verification passes before commit and push. + + +## Implementation Notes + + +Fetched the newest unresolved CodeRabbit threads for PR #49 after commit `942c1649`; only one unresolved actionable thread remained, on prerelease checksum output using repo-relative paths instead of asset basenames. + +Added regression coverage in `src/prerelease-workflow.test.ts` and `src/release-workflow.test.ts` asserting checksum generation truncates to asset basenames and no longer writes the raw `sha256sum "${files[@]}" > release/SHA256SUMS.txt` form. + +Updated both `.github/workflows/prerelease.yml` and `.github/workflows/release.yml` checksum generation steps to iterate over the `files` array and write `SHA256 basename` lines into `release/SHA256SUMS.txt`. + + +## Final Summary + + +Resolved the latest CodeRabbit round for PR #49 by fixing checksum generation to emit basename-oriented `SHA256SUMS.txt` entries in both prerelease and release workflows, with matching regression coverage. Verification: `bun test src/prerelease-workflow.test.ts src/release-workflow.test.ts`; `bun run typecheck`. + diff --git a/backlog/tasks/task-289 - Finish-current-windows-qol-rebase.md b/backlog/tasks/task-289 - Finish-current-windows-qol-rebase.md new file mode 100644 index 00000000..a7bafec3 --- /dev/null +++ b/backlog/tasks/task-289 - Finish-current-windows-qol-rebase.md @@ -0,0 +1,33 @@ +--- +id: TASK-289 +title: Finish current windows-qol rebase +status: Done +assignee: [] +created_date: '2026-04-11 22:07' +updated_date: '2026-04-11 22:08' +labels: + - maintenance + - rebase +dependencies: [] +references: + - /home/sudacode/projects/japanese/SubMiner +priority: medium +--- + +## Description + + +Resolve the in-progress rebase on `windows-qol` and ensure the branch lands cleanly. + + +## Acceptance Criteria + +- [x] #1 Interactive rebase completes without conflicts. +- [x] #2 Working tree is clean after the rebase finishes. + + +## Final Summary + + +Completed the interactive rebase on `windows-qol` and resolved the transient editor-blocked `git rebase --continue` step. Branch now rebased cleanly onto `49e46e6b`. + diff --git a/bun.lock b/bun.lock index 8cdb5f63..26231c43 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "commander": "^14.0.3", "hono": "^4.12.7", "jsonc-parser": "^3.3.1", + "koffi": "^2.15.6", "libsql": "^0.5.22", "ws": "^8.19.0", }, @@ -478,6 +479,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "koffi": ["koffi@2.15.6", "", {}, "sha512-WQBpM5uo74UQ17UpsFN+PUOrQQg4/nYdey4SGVluQun2drYYfePziLLWdSmFb4wSdWlJC1aimXQnjhPCheRKuw=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="], diff --git a/changes/2026-04-09-prerelease-workflow.md b/changes/2026-04-09-prerelease-workflow.md new file mode 100644 index 00000000..37941416 --- /dev/null +++ b/changes/2026-04-09-prerelease-workflow.md @@ -0,0 +1,5 @@ +type: internal +area: release + +- Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR. +- Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut. diff --git a/changes/README.md b/changes/README.md index 28dc8871..2f869c80 100644 --- a/changes/README.md +++ b/changes/README.md @@ -30,3 +30,9 @@ Rules: - each non-empty body line becomes a bullet - `README.md` is ignored by the generator - if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment + +Prerelease notes: + +- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md` +- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md` +- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes diff --git a/changes/fix-overlay-subtitle-drop-routing.md b/changes/fix-overlay-subtitle-drop-routing.md new file mode 100644 index 00000000..0711dee9 --- /dev/null +++ b/changes/fix-overlay-subtitle-drop-routing.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible. diff --git a/changes/fix-pr-49-coderabbit-review-follow-ups.md b/changes/fix-pr-49-coderabbit-review-follow-ups.md new file mode 100644 index 00000000..320cb661 --- /dev/null +++ b/changes/fix-pr-49-coderabbit-review-follow-ups.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Addressed the latest CodeRabbit follow-ups on PR #49, including generation-scoped Lua session binding names, stricter session command validation, session-help shortcut visibility, the numeric-selection key guard, stats-overlay startup classification, and safer session-binding persistence. diff --git a/changes/fix-windows-coderabbit-review-follow-ups.md b/changes/fix-windows-coderabbit-review-follow-ups.md new file mode 100644 index 00000000..46691491 --- /dev/null +++ b/changes/fix-windows-coderabbit-review-follow-ups.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Addressed the latest CodeRabbit follow-ups on the Windows overlay flow, including exact mpv target resolution, lower-overlay helper arguments, Win32 failure detection, and overlay cleanup on tracker loss. diff --git a/changes/fix-windows-overlay-z-order.md b/changes/fix-windows-overlay-z-order.md new file mode 100644 index 00000000..d5b1f57e --- /dev/null +++ b/changes/fix-windows-overlay-z-order.md @@ -0,0 +1,11 @@ +type: fixed +area: overlay + +- Fixed Windows overlay z-order so the visible subtitle overlay stops staying above unrelated apps after mpv loses focus. +- Fixed Windows overlay tracking to use native window polling and owner/z-order binding, which keeps the subtitle overlay aligned to the active mpv window more reliably. +- Fixed Windows overlay hide/restore behavior so minimizing mpv immediately hides the overlay and restoring mpv brings it back on top of the mpv window without requiring a click. +- Fixed stats overlay layering so the in-player stats page now stays above mpv and the subtitle overlay while it is open. +- Fixed Windows subtitle overlay stability so transient tracker misses and restore events keep the current subtitle visible instead of waiting for the next subtitle line. +- Fixed Windows focus handoff from the interactive subtitle overlay back to mpv so the overlay no longer drops behind mpv and briefly disappears. +- Fixed Windows visible-overlay startup so it no longer briefly opens as an interactive or opaque surface before the tracked transparent overlay state settles. +- Fixed spurious auto-pause after overlay visibility recovery and window resize so the overlay no longer pauses mpv until the pointer genuinely re-enters the subtitle area. diff --git a/changes/fix-windows-secondary-hover-titlebar.md b/changes/fix-windows-secondary-hover-titlebar.md new file mode 100644 index 00000000..94890fba --- /dev/null +++ b/changes/fix-windows-secondary-hover-titlebar.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed Windows secondary subtitle hover mode so the expanded hover hit area no longer blocks the native minimize, maximize, and close buttons. diff --git a/changes/fix-yomitan-nested-popup-focus.md b/changes/fix-yomitan-nested-popup-focus.md new file mode 100644 index 00000000..1a343cae --- /dev/null +++ b/changes/fix-yomitan-nested-popup-focus.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed Windows Yomitan popup focus loss after closing nested lookups so the original popup stays interactive instead of falling through to mpv. diff --git a/changes/honor-configured-controller-shortcuts-and-modal-routing.md b/changes/honor-configured-controller-shortcuts-and-modal-routing.md new file mode 100644 index 00000000..4e5d98d7 --- /dev/null +++ b/changes/honor-configured-controller-shortcuts-and-modal-routing.md @@ -0,0 +1,7 @@ +type: changed +area: overlay + +- Added configurable overlay shortcuts for session help, controller select, and controller debug actions. +- Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path. +- Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser. +- Fixed controller configuration and controller debug shortcut opens so configured bindings bring up their modals again instead of tripping renderer recovery. diff --git a/config.example.jsonc b/config.example.jsonc index efd0fd7d..92474731 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -173,7 +173,11 @@ "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. - "openJimaku": "Ctrl+Shift+J" // Open jimaku setting. + "openJimaku": "Ctrl+Shift+J", // Open jimaku setting. + "openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting. + "openControllerSelect": "Alt+C", // Open controller select setting. + "openControllerDebug": "Alt+Shift+C", // Open controller debug setting. + "toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== diff --git a/docs-site/configuration.md b/docs-site/configuration.md index dbf6725a..ea28a111 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -536,7 +536,11 @@ See `config.example.jsonc` for detailed configuration options. "mineSentenceMultiple": "CommandOrControl+Shift+S", "markAudioCard": "CommandOrControl+Shift+A", "openRuntimeOptions": "CommandOrControl+Shift+O", + "openSessionHelp": "CommandOrControl+Shift+H", + "openControllerSelect": "Alt+C", + "openControllerDebug": "Alt+Shift+C", "openJimaku": "Ctrl+Shift+J", + "toggleSubtitleSidebar": "Backslash", "multiCopyTimeoutMs": 3000 } } @@ -556,7 +560,11 @@ See `config.example.jsonc` for detailed configuration options. | `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | | `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | | `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | +| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Shift+H"`) | +| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | +| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | | `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | +| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | **See `config.example.jsonc`** for the complete list of shortcut configuration options. @@ -573,9 +581,10 @@ Important behavior: - Controller input is only active while keyboard-only mode is enabled. - Keyboard-only mode continues to work normally without a controller. - By default SubMiner uses the first connected controller. -- `Alt+C` opens the controller config modal, where you can save the selected controller and remap actions inline. +- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`. - Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action. -- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block. +- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`. +- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block. - `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`. - Turning keyboard-only mode off clears the keyboard-only token highlight state. - Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active. @@ -694,7 +703,7 @@ These shortcuts are only active when the overlay window is visible and automatic ### Session Help Modal -The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend. +The session help modal opens from the overlay with `Ctrl/Cmd+Shift+H` by default. The mpv plugin also exposes it through the `Y-H` chord (falling back to `Y-K` if needed). It shows the current session keybindings and color legend. You can filter the modal quickly with `/`: diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index efd0fd7d..92474731 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -173,7 +173,11 @@ "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. - "openJimaku": "Ctrl+Shift+J" // Open jimaku setting. + "openJimaku": "Ctrl+Shift+J", // Open jimaku setting. + "openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting. + "openControllerSelect": "Alt+C", // Open controller select setting. + "openControllerDebug": "Alt+Shift+C", // Open controller debug setting. + "toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. // ========================================== diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md index 2310f549..a79ee7a0 100644 --- a/docs-site/shortcuts.md +++ b/docs-site/shortcuts.md @@ -67,6 +67,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle | ------------------ | -------------------------------------------------------- | ------------------------------ | | `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | | `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | +| `Ctrl/Cmd+Shift+H` | Open session help modal | `shortcuts.openSessionHelp` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | @@ -79,12 +80,12 @@ The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a ## Controller Shortcuts -These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration. +These overlay-local shortcuts open controller utilities for the Chrome Gamepad API integration. -| Shortcut | Action | Configurable | -| ------------- | ------------------------------ | ------------ | -| `Alt+C` | Open controller config + remap modal | Fixed | -| `Alt+Shift+C` | Open controller debug modal | Fixed | +| Shortcut | Action | Configurable | +| ------------- | ------------------------------------ | -------------------------------- | +| `Alt+C` | Open controller config + remap modal | `shortcuts.openControllerSelect` | +| `Alt+Shift+C` | Open controller debug modal | `shortcuts.openControllerDebug` | Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller. @@ -101,6 +102,7 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press | `y-o` | Open Yomitan settings | | `y-r` | Restart overlay | | `y-c` | Check overlay status | +| `y-h` | Open session help | When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper). diff --git a/docs-site/usage.md b/docs-site/usage.md index 96e4b583..0d446d4a 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -272,12 +272,12 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro 1. Connect a controller before or after launching SubMiner. 2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding. -3. Press `Alt+C` in the overlay to pick the controller you want to save and remap any action inline. +3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline. 4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller. 5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps. 6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup. -By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline. `Alt+Shift+C` still opens the live debug modal with raw axes/button values for non-standard pads. +By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`. ### Default Button Mapping @@ -321,6 +321,8 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback. +`Ctrl/Cmd+Shift+H` opens the session help modal with the current overlay and mpv keybindings. If you use the mpv plugin, the same help view is also available through the `y-h` chord. + Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior. ### Drag-and-Drop diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 25d9da8c..6c0c4d47 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -2,6 +2,8 @@ # Releasing +## Stable Release + 1. Confirm `main` is green: `gh run list --workflow CI --limit 5`. 2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples. 3. Run `bun run changelog:lint`. @@ -24,15 +26,37 @@ 10. Tag the commit: `git tag v`. 11. Push commit + tag. +## Prerelease + +1. Confirm release-facing docs and pending `changes/*.md` fragments are current. +2. Run `bun run changelog:lint`. +3. Bump `package.json` to the prerelease version, for example `0.11.3-beta.1` or `0.11.3-rc.1`. +4. Run the prerelease gate locally: + `bun run changelog:prerelease-notes --version ` + `bun run verify:config-example` + `bun run typecheck` + `bun run test:fast` + `bun run test:env` + `bun run build` +5. Commit the prerelease prep. Do not run `bun run changelog:build`. +6. Tag the commit: `git tag v`. +7. Push commit + tag. + +Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes. + Notes: - Versioning policy: SubMiner stays 0-ver. Large or breaking release lines still bump the minor number (`0.x.0`), not `1.0.0`. Example: the next major line after `0.6.5` is `0.7.0`. +- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`. - Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night. - `changelog:check` now rejects tag/package version mismatches. +- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments. - In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes. - Do not tag while `changes/*.md` fragments still exist. +- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. - If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`. +- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job. - Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication. - AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. diff --git a/package.json b/package.json index 2cce9017..294c2d5d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "subminer", "productName": "SubMiner", "desktopName": "SubMiner.desktop", - "version": "0.11.2", + "version": "0.12.0-beta.3", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "packageManager": "bun@1.3.5", "main": "dist/main-entry.js", @@ -26,6 +26,7 @@ "changelog:lint": "bun run scripts/build-changelog.ts lint", "changelog:pr-check": "bun run scripts/build-changelog.ts pr-check", "changelog:release-notes": "bun run scripts/build-changelog.ts release-notes", + "changelog:prerelease-notes": "bun run scripts/build-changelog.ts prerelease-notes", "format": "prettier --write .", "format:check": "prettier --check .", "format:src": "bash scripts/prettier-scope.sh --write", @@ -69,7 +70,7 @@ "test:launcher": "bun run test:launcher:src", "test:core": "bun run test:core:src", "test:subtitle": "bun run test:subtitle:src", - "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", + "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", "generate:config-example": "bun run src/generate-config-example.ts", "verify:config-example": "bun run src/verify-config-example.ts", "start": "bun run build && electron . --start", @@ -112,6 +113,7 @@ "commander": "^14.0.3", "hono": "^4.12.7", "jsonc-parser": "^3.3.1", + "koffi": "^2.15.6", "libsql": "^0.5.22", "ws": "^8.19.0" }, diff --git a/plugin/subminer/bootstrap.lua b/plugin/subminer/bootstrap.lua index 62eaabf0..5a05c6ec 100644 --- a/plugin/subminer/bootstrap.lua +++ b/plugin/subminer/bootstrap.lua @@ -14,7 +14,7 @@ function M.init() local utils = require("mp.utils") local options_helper = require("options") - local environment = require("environment").create({ mp = mp }) + local environment = require("environment").create({ mp = mp, utils = utils }) local opts = options_helper.load(options_lib, environment.default_socket_path()) local state = require("state").new() @@ -61,6 +61,9 @@ function M.init() ctx.process = make_lazy_proxy("process", function() return require("process").create(ctx) end) + ctx.session_bindings = make_lazy_proxy("session_bindings", function() + return require("session_bindings").create(ctx) + end) ctx.ui = make_lazy_proxy("ui", function() return require("ui").create(ctx) end) @@ -72,6 +75,7 @@ function M.init() end) ctx.ui.register_keybindings() + ctx.session_bindings.register_bindings() ctx.messages.register_script_messages() ctx.lifecycle.register_lifecycle_hooks() ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded") diff --git a/plugin/subminer/environment.lua b/plugin/subminer/environment.lua index a6e4d20c..df5a1f3e 100644 --- a/plugin/subminer/environment.lua +++ b/plugin/subminer/environment.lua @@ -1,7 +1,9 @@ local M = {} +local unpack_fn = table.unpack or unpack function M.create(ctx) local mp = ctx.mp + local utils = ctx.utils local detected_backend = nil local app_running_cache_value = nil @@ -30,6 +32,63 @@ function M.create(ctx) return "/tmp/subminer-socket" end + local function path_separator() + return is_windows() and "\\" or "/" + end + + local function join_path(...) + local parts = { ... } + if utils and type(utils.join_path) == "function" then + return utils.join_path(unpack_fn(parts)) + end + return table.concat(parts, path_separator()) + end + + local function file_exists(path) + if not utils or type(utils.file_info) ~= "function" then + return false + end + return utils.file_info(path) ~= nil + end + + local function resolve_subminer_config_dir() + local home = os.getenv("HOME") or os.getenv("USERPROFILE") or "" + local candidates = {} + if is_windows() then + local app_data = os.getenv("APPDATA") or join_path(home, "AppData", "Roaming") + candidates = { + join_path(app_data, "SubMiner"), + } + else + local xdg_config_home = os.getenv("XDG_CONFIG_HOME") + local primary_base = (type(xdg_config_home) == "string" and xdg_config_home ~= "") + and xdg_config_home + or join_path(home, ".config") + candidates = { + join_path(primary_base, "SubMiner"), + join_path(home, ".config", "SubMiner"), + } + end + + for _, dir in ipairs(candidates) do + if file_exists(join_path(dir, "config.jsonc")) or file_exists(join_path(dir, "config.json")) then + return dir + end + end + + for _, dir in ipairs(candidates) do + if file_exists(dir) then + return dir + end + end + + return candidates[1] + end + + local function resolve_session_bindings_artifact_path() + return join_path(resolve_subminer_config_dir(), "session-bindings.json") + end + local function is_linux() return not is_windows() and not is_macos() end @@ -191,7 +250,10 @@ function M.create(ctx) is_windows = is_windows, is_macos = is_macos, is_linux = is_linux, + join_path = join_path, default_socket_path = default_socket_path, + resolve_subminer_config_dir = resolve_subminer_config_dir, + resolve_session_bindings_artifact_path = resolve_session_bindings_artifact_path, is_subminer_process_running = is_subminer_process_running, is_subminer_app_running = is_subminer_app_running, is_subminer_app_running_async = is_subminer_app_running_async, diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua index 44c5ade9..a62824da 100644 --- a/plugin/subminer/messages.lua +++ b/plugin/subminer/messages.lua @@ -47,6 +47,9 @@ function M.create(ctx) mp.register_script_message("subminer-stats-toggle", function() mp.osd_message("Stats: press ` (backtick) in overlay", 3) end) + mp.register_script_message("subminer-reload-session-bindings", function() + ctx.session_bindings.reload_bindings() + end) end return { diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index d4f9a723..e3046187 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -229,6 +229,22 @@ function M.create(ctx) end) end + local function run_binary_command_async(args, callback) + subminer_log("debug", "process", "Binary command: " .. table.concat(args, " ")) + mp.command_native_async({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + local ok = success and (result == nil or result.status == 0) + if callback then + callback(ok, result, error) + end + end) + end + local function parse_start_script_message_overrides(...) local overrides = {} for i = 1, select("#", ...) do @@ -528,6 +544,7 @@ function M.create(ctx) build_command_args = build_command_args, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, run_control_command_async = run_control_command_async, + run_binary_command_async = run_binary_command_async, parse_start_script_message_overrides = parse_start_script_message_overrides, ensure_texthooker_running = ensure_texthooker_running, start_overlay = start_overlay, diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua new file mode 100644 index 00000000..718a1041 --- /dev/null +++ b/plugin/subminer/session_bindings.lua @@ -0,0 +1,359 @@ +local M = {} + +local unpack_fn = table.unpack or unpack + +local KEY_NAME_MAP = { + Space = "SPACE", + Tab = "TAB", + Enter = "ENTER", + Escape = "ESC", + Backspace = "BS", + Delete = "DEL", + ArrowUp = "UP", + ArrowDown = "DOWN", + ArrowLeft = "LEFT", + ArrowRight = "RIGHT", + Slash = "/", + Backslash = "\\", + Minus = "-", + Equal = "=", + Comma = ",", + Period = ".", + Quote = "'", + Semicolon = ";", + BracketLeft = "[", + BracketRight = "]", + Backquote = "`", +} + +local MODIFIER_MAP = { + ctrl = "Ctrl", + alt = "Alt", + shift = "Shift", + meta = "Meta", +} + +function M.create(ctx) + local mp = ctx.mp + local utils = ctx.utils + local state = ctx.state + local process = ctx.process + local environment = ctx.environment + local subminer_log = ctx.log.subminer_log + local show_osd = ctx.log.show_osd + + local function read_file(path) + local handle = io.open(path, "r") + if not handle then + return nil + end + local content = handle:read("*a") + handle:close() + return content + end + + local function remove_binding_names(names) + for _, name in ipairs(names) do + mp.remove_key_binding(name) + end + for index = #names, 1, -1 do + names[index] = nil + end + end + + local function key_code_to_mpv_name(code) + if KEY_NAME_MAP[code] then + return KEY_NAME_MAP[code] + end + + local letter = code:match("^Key([A-Z])$") + if letter then + return string.lower(letter) + end + + local digit = code:match("^Digit([0-9])$") + if digit then + return digit + end + + local function_key = code:match("^(F%d+)$") + if function_key then + return function_key + end + + return nil + end + + local function key_spec_to_mpv_binding(key) + if type(key) ~= "table" then + return nil + end + + if type(key.code) ~= "string" then + return nil + end + if type(key.modifiers) ~= "table" then + return nil + end + + local key_name = key_code_to_mpv_name(key.code) + if not key_name then + return nil + end + + local parts = {} + for _, modifier in ipairs(key.modifiers) do + local mapped = MODIFIER_MAP[modifier] + if mapped then + parts[#parts + 1] = mapped + end + end + parts[#parts + 1] = key_name + return table.concat(parts, "+") + end + + local function build_cli_args(action_id, payload) + if action_id == "toggleVisibleOverlay" then + return { "--toggle-visible-overlay" } + elseif action_id == "toggleStatsOverlay" then + return { "--toggle-stats-overlay" } + elseif action_id == "copySubtitle" then + return { "--copy-subtitle" } + elseif action_id == "copySubtitleMultiple" then + return { "--copy-subtitle-count", tostring(payload and payload.count or 1) } + elseif action_id == "updateLastCardFromClipboard" then + return { "--update-last-card-from-clipboard" } + elseif action_id == "triggerFieldGrouping" then + return { "--trigger-field-grouping" } + elseif action_id == "triggerSubsync" then + return { "--trigger-subsync" } + elseif action_id == "mineSentence" then + return { "--mine-sentence" } + elseif action_id == "mineSentenceMultiple" then + return { "--mine-sentence-count", tostring(payload and payload.count or 1) } + elseif action_id == "toggleSecondarySub" then + return { "--toggle-secondary-sub" } + elseif action_id == "toggleSubtitleSidebar" then + return { "--toggle-subtitle-sidebar" } + elseif action_id == "markAudioCard" then + return { "--mark-audio-card" } + elseif action_id == "openRuntimeOptions" then + return { "--open-runtime-options" } + elseif action_id == "openJimaku" then + return { "--open-jimaku" } + elseif action_id == "openYoutubePicker" then + return { "--open-youtube-picker" } + elseif action_id == "openSessionHelp" then + return { "--open-session-help" } + elseif action_id == "openControllerSelect" then + return { "--open-controller-select" } + elseif action_id == "openControllerDebug" then + return { "--open-controller-debug" } + elseif action_id == "openPlaylistBrowser" then + return { "--open-playlist-browser" } + elseif action_id == "replayCurrentSubtitle" then + return { "--replay-current-subtitle" } + elseif action_id == "playNextSubtitle" then + return { "--play-next-subtitle" } + elseif action_id == "shiftSubDelayPrevLine" then + return { "--shift-sub-delay-prev-line" } + elseif action_id == "shiftSubDelayNextLine" then + return { "--shift-sub-delay-next-line" } + elseif action_id == "cycleRuntimeOption" then + local runtime_option_id = payload and payload.runtimeOptionId or nil + if type(runtime_option_id) ~= "string" or runtime_option_id == "" then + return nil + end + local direction = payload and payload.direction == -1 and "prev" or "next" + return { "--cycle-runtime-option", runtime_option_id .. ":" .. direction } + end + + return nil + end + + local function invoke_cli_action(action_id, payload) + if not process.check_binary_available() then + show_osd("Error: binary not found") + return + end + + local cli_args = build_cli_args(action_id, payload) + if not cli_args then + subminer_log("warn", "session-bindings", "No CLI mapping for action: " .. tostring(action_id)) + return + end + + local args = { state.binary_path } + for _, arg in ipairs(cli_args) do + args[#args + 1] = arg + end + local runner = process.run_binary_command_async + if type(runner) ~= "function" then + runner = function(binary_args, callback) + mp.command_native_async({ + name = "subprocess", + args = binary_args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + local ok = success and (result == nil or result.status == 0) + if callback then + callback(ok, result, error) + end + end) + end + end + runner(args, function(ok, result, error) + if ok then + return + end + local reason = error or (result and result.stderr) or "unknown error" + subminer_log("warn", "session-bindings", "Session action failed: " .. tostring(reason)) + show_osd("Session action failed") + end) + end + + local function clear_numeric_selection(show_cancelled) + if state.session_numeric_selection and state.session_numeric_selection.timeout then + state.session_numeric_selection.timeout:kill() + end + state.session_numeric_selection = nil + remove_binding_names(state.session_numeric_binding_names) + if show_cancelled then + show_osd("Cancelled") + end + end + + local function start_numeric_selection(action_id, timeout_ms) + clear_numeric_selection(false) + for digit = 1, 9 do + local digit_string = tostring(digit) + local name = "subminer-session-digit-" .. digit_string + state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name + mp.add_forced_key_binding(digit_string, name, function() + clear_numeric_selection(false) + invoke_cli_action(action_id, { count = digit }) + end) + end + + state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = + "subminer-session-digit-cancel" + mp.add_forced_key_binding("ESC", "subminer-session-digit-cancel", function() + clear_numeric_selection(true) + end) + + state.session_numeric_selection = { + action_id = action_id, + timeout = mp.add_timeout((timeout_ms or 3000) / 1000, function() + clear_numeric_selection(false) + show_osd(action_id == "copySubtitleMultiple" and "Copy timeout" or "Mine timeout") + end), + } + + show_osd( + action_id == "copySubtitleMultiple" + and "Copy how many lines? Press 1-9 (Esc to cancel)" + or "Mine how many lines? Press 1-9 (Esc to cancel)" + ) + end + + local function execute_mpv_command(command) + if type(command) ~= "table" or command[1] == nil then + return + end + mp.commandv(unpack_fn(command)) + end + + local function handle_binding(binding, numeric_selection_timeout_ms) + if binding.actionType == "mpv-command" then + execute_mpv_command(binding.command) + return + end + + if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then + start_numeric_selection(binding.actionId, numeric_selection_timeout_ms) + return + end + + invoke_cli_action(binding.actionId, binding.payload) + end + + local function load_artifact() + local artifact_path = environment.resolve_session_bindings_artifact_path() + local raw = read_file(artifact_path) + if not raw or raw == "" then + return nil, "Missing session binding artifact: " .. tostring(artifact_path) + end + + local parsed, parse_error = utils.parse_json(raw) + if not parsed then + return nil, "Failed to parse session binding artifact: " .. tostring(parse_error) + end + if type(parsed) ~= "table" or type(parsed.bindings) ~= "table" then + return nil, "Invalid session binding artifact" + end + + return parsed, nil + end + + local function clear_bindings() + clear_numeric_selection(false) + remove_binding_names(state.session_binding_names) + end + + local function register_bindings() + local artifact, load_error = load_artifact() + if not artifact then + subminer_log("warn", "session-bindings", load_error) + return false + end + + clear_numeric_selection(false) + + local previous_binding_names = state.session_binding_names + local next_binding_names = {} + state.session_binding_generation = (state.session_binding_generation or 0) + 1 + local generation = state.session_binding_generation + + local timeout_ms = tonumber(artifact.numericSelectionTimeoutMs) or 3000 + for index, binding in ipairs(artifact.bindings) do + local key_name = key_spec_to_mpv_binding(binding.key) + if key_name then + local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index) + next_binding_names[#next_binding_names + 1] = name + mp.add_forced_key_binding(key_name, name, function() + handle_binding(binding, timeout_ms) + end) + else + subminer_log( + "warn", + "session-bindings", + "Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown") + ) + end + end + + remove_binding_names(previous_binding_names) + state.session_binding_names = next_binding_names + + subminer_log( + "info", + "session-bindings", + "Registered " .. tostring(#next_binding_names) .. " shared session bindings" + ) + return true + end + + local function reload_bindings() + return register_bindings() + end + + return { + register_bindings = register_bindings, + reload_bindings = reload_bindings, + clear_bindings = clear_bindings, + } +end + +return M diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 8814b0ee..6573f8c0 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -33,6 +33,10 @@ function M.new() auto_play_ready_timeout = nil, auto_play_ready_osd_timer = nil, suppress_ready_overlay_restore = false, + session_binding_generation = 0, + session_binding_names = {}, + session_numeric_binding_names = {}, + session_numeric_selection = nil, } end diff --git a/plugin/subminer/ui.lua b/plugin/subminer/ui.lua index f4ff0e4c..92cbabb5 100644 --- a/plugin/subminer/ui.lua +++ b/plugin/subminer/ui.lua @@ -90,6 +90,12 @@ function M.create(ctx) mp.add_key_binding("y-c", "subminer-status", function() process.check_status() end) + mp.add_key_binding("y-h", "subminer-session-help", function() + if not ensure_binary_for_menu() then + return + end + process.run_control_command_async("open-session-help") + end) if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function() aniskip.skip_intro_now() diff --git a/scripts/build-changelog.test.ts b/scripts/build-changelog.test.ts index d100e1a6..9c835870 100644 --- a/scripts/build-changelog.test.ts +++ b/scripts/build-changelog.test.ts @@ -310,3 +310,186 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and }), ); }); + +test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-beta-notes'); + const projectRoot = path.join(workspace, 'SubMiner'); + const changelogPath = path.join(projectRoot, 'CHANGELOG.md'); + const docsChangelogPath = path.join(projectRoot, 'docs-site', 'changelog.md'); + const existingChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable release.\n'; + const existingDocsChangelog = '# Changelog\n\n## v0.11.2 (2026-04-07)\n- Stable docs release.\n'; + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, 'docs-site'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2), + 'utf8', + ); + fs.writeFileSync(changelogPath, existingChangelog, 'utf8'); + fs.writeFileSync(docsChangelogPath, existingDocsChangelog, 'utf8'); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: added', 'area: overlay', '', '- Added prerelease coverage.'].join('\n'), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', '002.md'), + ['type: fixed', 'area: launcher', '', '- Fixed prerelease packaging checks.'].join('\n'), + 'utf8', + ); + + try { + const outputPath = writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.11.3-beta.1', + }); + + assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md')); + assert.equal( + fs.readFileSync(changelogPath, 'utf8'), + existingChangelog, + 'stable CHANGELOG.md should remain unchanged', + ); + assert.equal( + fs.readFileSync(docsChangelogPath, 'utf8'), + existingDocsChangelog, + 'docs-site changelog should remain unchanged', + ); + assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '001.md')), true); + assert.equal(fs.existsSync(path.join(projectRoot, 'changes', '002.md')), true); + + const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); + assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m); + assert.match(prereleaseNotes, /## Highlights\n### Added\n- Overlay: Added prerelease coverage\./); + assert.match( + prereleaseNotes, + /### Fixed\n- Launcher: Fixed prerelease packaging checks\./, + ); + assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('writePrereleaseNotesForVersion supports rc prereleases', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-rc-notes'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.11.3-rc.1' }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: changed', 'area: release', '', '- Prepared release candidate notes.'].join('\n'), + 'utf8', + ); + + try { + const outputPath = writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.11.3-rc.1', + }); + + const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); + assert.match( + prereleaseNotes, + /## Highlights\n### Changed\n- Release: Prepared release candidate notes\./, + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('writePrereleaseNotesForVersion rejects unsupported prerelease identifiers', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-alpha-reject'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.11.3-alpha.1' }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: added', 'area: overlay', '', '- Unsupported alpha prerelease.'].join('\n'), + 'utf8', + ); + + try { + assert.throws( + () => + writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.11.3-alpha.1', + }), + /Unsupported prerelease version \(0\.11\.3-alpha\.1\)/, + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('writePrereleaseNotesForVersion rejects mismatched package versions', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-version-mismatch'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'changes', '001.md'), + ['type: added', 'area: overlay', '', '- Mismatched prerelease.'].join('\n'), + 'utf8', + ); + + try { + assert.throws( + () => + writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.11.3-beta.2', + }), + /package\.json version \(0\.11\.3-beta\.1\) does not match requested release version \(0\.11\.3-beta\.2\)/, + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test('writePrereleaseNotesForVersion rejects empty prerelease note generation when no fragments exist', async () => { + const { writePrereleaseNotesForVersion } = await loadModule(); + const workspace = createWorkspace('prerelease-no-fragments'); + const projectRoot = path.join(workspace, 'SubMiner'); + + fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2), + 'utf8', + ); + + try { + assert.throws( + () => + writePrereleaseNotesForVersion({ + cwd: projectRoot, + version: '0.11.3-beta.1', + }), + /No changelog fragments found in changes\//, + ); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/scripts/build-changelog.ts b/scripts/build-changelog.ts index 63bc2d3a..c5fe4158 100644 --- a/scripts/build-changelog.ts +++ b/scripts/build-changelog.ts @@ -38,6 +38,7 @@ type PullRequestChangelogOptions = { }; const RELEASE_NOTES_PATH = path.join('release', 'release-notes.md'); +const PRERELEASE_NOTES_PATH = path.join('release', 'prerelease-notes.md'); const CHANGELOG_HEADER = '# Changelog'; const CHANGE_TYPES: FragmentType[] = ['added', 'changed', 'fixed', 'docs', 'internal']; const CHANGE_TYPE_HEADINGS: Record = { @@ -75,6 +76,10 @@ function resolveVersion(options: Pick, ): void { @@ -314,8 +319,15 @@ export function resolveChangelogOutputPaths(options?: { cwd?: string }): string[ return [path.join(cwd, 'CHANGELOG.md')]; } -function renderReleaseNotes(changes: string): string { +function renderReleaseNotes( + changes: string, + options?: { + disclaimer?: string; + }, +): string { + const prefix = options?.disclaimer ? [options.disclaimer, ''] : []; return [ + ...prefix, '## Highlights', changes, '', @@ -334,13 +346,21 @@ function renderReleaseNotes(changes: string): string { ].join('\n'); } -function writeReleaseNotesFile(cwd: string, changes: string, deps?: ChangelogFsDeps): string { +function writeReleaseNotesFile( + cwd: string, + changes: string, + deps?: ChangelogFsDeps, + options?: { + disclaimer?: string; + outputPath?: string; + }, +): string { const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; const writeFileSync = deps?.writeFileSync ?? fs.writeFileSync; - const releaseNotesPath = path.join(cwd, RELEASE_NOTES_PATH); + const releaseNotesPath = path.join(cwd, options?.outputPath ?? RELEASE_NOTES_PATH); mkdirSync(path.dirname(releaseNotesPath), { recursive: true }); - writeFileSync(releaseNotesPath, renderReleaseNotes(changes), 'utf8'); + writeFileSync(releaseNotesPath, renderReleaseNotes(changes, options), 'utf8'); return releaseNotesPath; } @@ -613,6 +633,30 @@ export function writeReleaseNotesForVersion(options?: ChangelogOptions): string return writeReleaseNotesFile(cwd, changes, options?.deps); } +export function writePrereleaseNotesForVersion(options?: ChangelogOptions): string { + verifyRequestedVersionMatchesPackageVersion(options ?? {}); + + const cwd = options?.cwd ?? process.cwd(); + const version = resolveVersion(options ?? {}); + if (!isSupportedPrereleaseVersion(version)) { + throw new Error( + `Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`, + ); + } + + const fragments = readChangeFragments(cwd, options?.deps); + if (fragments.length === 0) { + throw new Error('No changelog fragments found in changes/.'); + } + + const changes = renderGroupedChanges(fragments); + return writeReleaseNotesFile(cwd, changes, options?.deps, { + disclaimer: + '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', + outputPath: PRERELEASE_NOTES_PATH, + }); +} + function parseCliArgs(argv: string[]): { baseRef?: string; cwd?: string; @@ -710,6 +754,11 @@ function main(): void { return; } + if (command === 'prerelease-notes') { + writePrereleaseNotesForVersion(options); + return; + } + if (command === 'docs') { generateDocsChangelog(options); return; diff --git a/scripts/get-mpv-window-windows.ps1 b/scripts/get-mpv-window-windows.ps1 deleted file mode 100644 index 2c5ef794..00000000 --- a/scripts/get-mpv-window-windows.ps1 +++ /dev/null @@ -1,175 +0,0 @@ -param( - [ValidateSet('geometry')] - [string]$Mode = 'geometry', - [string]$SocketPath -) - -$ErrorActionPreference = 'Stop' - -try { - Add-Type -TypeDefinition @" -using System; -using System.Runtime.InteropServices; - -public static class SubMinerWindowsHelper { - public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); - - [StructLayout(LayoutKind.Sequential)] - public struct RECT { - public int Left; - public int Top; - public int Right; - public int Bottom; - } - - [DllImport("user32.dll")] - public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); - - [DllImport("user32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool IsWindowVisible(IntPtr hWnd); - - [DllImport("user32.dll")] - public static extern bool IsIconic(IntPtr hWnd); - - [DllImport("user32.dll")] - public static extern IntPtr GetForegroundWindow(); - - [DllImport("user32.dll", SetLastError = true)] - public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); - - [DllImport("dwmapi.dll")] - public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute); -} -"@ - - $DWMWA_EXTENDED_FRAME_BOUNDS = 9 - - function Get-WindowBounds { - param([IntPtr]$hWnd) - - $rect = New-Object SubMinerWindowsHelper+RECT - $size = [System.Runtime.InteropServices.Marshal]::SizeOf($rect) - $dwmResult = [SubMinerWindowsHelper]::DwmGetWindowAttribute( - $hWnd, - $DWMWA_EXTENDED_FRAME_BOUNDS, - [ref]$rect, - $size - ) - - if ($dwmResult -ne 0) { - if (-not [SubMinerWindowsHelper]::GetWindowRect($hWnd, [ref]$rect)) { - return $null - } - } - - $width = $rect.Right - $rect.Left - $height = $rect.Bottom - $rect.Top - if ($width -le 0 -or $height -le 0) { - return $null - } - - return [PSCustomObject]@{ - X = $rect.Left - Y = $rect.Top - Width = $width - Height = $height - Area = $width * $height - } - } - - $commandLineByPid = @{} - if (-not [string]::IsNullOrWhiteSpace($SocketPath)) { - foreach ($process in Get-CimInstance Win32_Process) { - $commandLineByPid[[uint32]$process.ProcessId] = $process.CommandLine - } - } - - $mpvMatches = New-Object System.Collections.Generic.List[object] - $foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow() - $callback = [SubMinerWindowsHelper+EnumWindowsProc]{ - param([IntPtr]$hWnd, [IntPtr]$lParam) - - if (-not [SubMinerWindowsHelper]::IsWindowVisible($hWnd)) { - return $true - } - - if ([SubMinerWindowsHelper]::IsIconic($hWnd)) { - return $true - } - - [uint32]$windowProcessId = 0 - [void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId) - if ($windowProcessId -eq 0) { - return $true - } - - try { - $process = Get-Process -Id $windowProcessId -ErrorAction Stop - } catch { - return $true - } - - if ($process.ProcessName -ine 'mpv') { - return $true - } - - if (-not [string]::IsNullOrWhiteSpace($SocketPath)) { - $commandLine = $commandLineByPid[[uint32]$windowProcessId] - if ([string]::IsNullOrWhiteSpace($commandLine)) { - return $true - } - if ( - ($commandLine -notlike "*--input-ipc-server=$SocketPath*") -and - ($commandLine -notlike "*--input-ipc-server $SocketPath*") - ) { - return $true - } - } - - $bounds = Get-WindowBounds -hWnd $hWnd - if ($null -eq $bounds) { - return $true - } - - $mpvMatches.Add([PSCustomObject]@{ - HWnd = $hWnd - X = $bounds.X - Y = $bounds.Y - Width = $bounds.Width - Height = $bounds.Height - Area = $bounds.Area - IsForeground = ($foregroundWindow -ne [IntPtr]::Zero -and $hWnd -eq $foregroundWindow) - }) - - return $true - } - - [void][SubMinerWindowsHelper]::EnumWindows($callback, [IntPtr]::Zero) - - $focusedMatch = $mpvMatches | Where-Object { $_.IsForeground } | Select-Object -First 1 - if ($null -ne $focusedMatch) { - [Console]::Error.WriteLine('focus=focused') - } else { - [Console]::Error.WriteLine('focus=not-focused') - } - - if ($mpvMatches.Count -eq 0) { - Write-Output 'not-found' - exit 0 - } - - $bestMatch = if ($null -ne $focusedMatch) { - $focusedMatch - } else { - $mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1 - } - Write-Output "$($bestMatch.X),$($bestMatch.Y),$($bestMatch.Width),$($bestMatch.Height)" -} catch { - [Console]::Error.WriteLine($_.Exception.Message) - exit 1 -} diff --git a/scripts/mkv-to-readme-video.sh b/scripts/mkv-to-readme-video.sh index 04fbd6ab..faadd370 100755 --- a/scripts/mkv-to-readme-video.sh +++ b/scripts/mkv-to-readme-video.sh @@ -28,6 +28,27 @@ USAGE force=0 generate_webp=0 input="" +ffmpeg_bin="${FFMPEG_BIN:-ffmpeg}" + +normalize_path() { + local value="$1" + if command -v cygpath > /dev/null 2>&1; then + case "$value" in + [A-Za-z]:\\* | [A-Za-z]:/*) + cygpath -u "$value" + return 0 + ;; + esac + fi + if [[ "$value" =~ ^([A-Za-z]):[\\/](.*)$ ]]; then + local drive="${BASH_REMATCH[1],,}" + local rest="${BASH_REMATCH[2]}" + rest="${rest//\\//}" + printf '/mnt/%s/%s\n' "$drive" "$rest" + return 0 + fi + printf '%s\n' "$value" +} while [[ $# -gt 0 ]]; do case "$1" in @@ -63,9 +84,19 @@ if [[ -z "$input" ]]; then exit 1 fi -if ! command -v ffmpeg > /dev/null 2>&1; then - echo "Error: ffmpeg is not installed or not in PATH." >&2 - exit 1 +input="$(normalize_path "$input")" +ffmpeg_bin="$(normalize_path "$ffmpeg_bin")" + +if [[ "$ffmpeg_bin" == */* ]]; then + if [[ ! -x "$ffmpeg_bin" ]]; then + echo "Error: ffmpeg binary is not executable: $ffmpeg_bin" >&2 + exit 1 + fi +else + if ! command -v "$ffmpeg_bin" > /dev/null 2>&1; then + echo "Error: ffmpeg is not installed or not in PATH." >&2 + exit 1 + fi fi if [[ ! -f "$input" ]]; then @@ -102,7 +133,7 @@ fi has_encoder() { local encoder="$1" - ffmpeg -hide_banner -encoders 2> /dev/null | awk -v encoder="$encoder" '$2 == encoder { found = 1 } END { exit(found ? 0 : 1) }' + "$ffmpeg_bin" -hide_banner -encoders 2> /dev/null | awk -v encoder="$encoder" '$2 == encoder { found = 1 } END { exit(found ? 0 : 1) }' } pick_webp_encoder() { @@ -123,7 +154,7 @@ webm_vf="${crop_vf},fps=30" echo "Generating MP4: $mp4_out" if has_encoder "h264_nvenc"; then echo "Trying GPU encoder for MP4: h264_nvenc" - if ffmpeg "$overwrite_flag" -i "$input" \ + if "$ffmpeg_bin" "$overwrite_flag" -i "$input" \ -vf "$crop_vf" \ -c:v h264_nvenc -preset p6 -rc:v vbr -cq:v 20 -b:v 0 \ -pix_fmt yuv420p -movflags +faststart \ @@ -132,7 +163,7 @@ if has_encoder "h264_nvenc"; then : else echo "GPU MP4 encode failed; retrying with CPU encoder: libx264" - ffmpeg "$overwrite_flag" -i "$input" \ + "$ffmpeg_bin" "$overwrite_flag" -i "$input" \ -vf "$crop_vf" \ -c:v libx264 -preset slow -crf 20 \ -profile:v high -level 4.1 -pix_fmt yuv420p \ @@ -142,7 +173,7 @@ if has_encoder "h264_nvenc"; then fi else echo "Using CPU encoder for MP4: libx264" - ffmpeg "$overwrite_flag" -i "$input" \ + "$ffmpeg_bin" "$overwrite_flag" -i "$input" \ -vf "$crop_vf" \ -c:v libx264 -preset slow -crf 20 \ -profile:v high -level 4.1 -pix_fmt yuv420p \ @@ -154,7 +185,7 @@ fi echo "Generating WebM: $webm_out" if has_encoder "av1_nvenc"; then echo "Trying GPU encoder for WebM: av1_nvenc" - if ffmpeg "$overwrite_flag" -i "$input" \ + if "$ffmpeg_bin" "$overwrite_flag" -i "$input" \ -vf "$webm_vf" \ -c:v av1_nvenc -preset p6 -cq:v 34 -b:v 0 \ -c:a libopus -b:a 96k \ @@ -162,7 +193,7 @@ if has_encoder "av1_nvenc"; then : else echo "GPU WebM encode failed; retrying with CPU encoder: libvpx-vp9" - ffmpeg "$overwrite_flag" -i "$input" \ + "$ffmpeg_bin" "$overwrite_flag" -i "$input" \ -vf "$webm_vf" \ -c:v libvpx-vp9 -crf 34 -b:v 0 \ -row-mt 1 -threads 8 \ @@ -171,7 +202,7 @@ if has_encoder "av1_nvenc"; then fi else echo "Using CPU encoder for WebM: libvpx-vp9" - ffmpeg "$overwrite_flag" -i "$input" \ + "$ffmpeg_bin" "$overwrite_flag" -i "$input" \ -vf "$webm_vf" \ -c:v libvpx-vp9 -crf 34 -b:v 0 \ -row-mt 1 -threads 8 \ @@ -185,7 +216,7 @@ if [[ "$generate_webp" -eq 1 ]]; then exit 1 fi echo "Generating animated WebP with $webp_encoder: $webp_out" - ffmpeg "$overwrite_flag" -i "$input" \ + "$ffmpeg_bin" "$overwrite_flag" -i "$input" \ -vf "${crop_vf},fps=24,scale=960:-1:flags=lanczos" \ -c:v "$webp_encoder" \ -q:v 80 \ @@ -195,7 +226,7 @@ if [[ "$generate_webp" -eq 1 ]]; then fi echo "Generating poster: $poster_out" -ffmpeg "$overwrite_flag" -ss 00:00:05 -i "$input" \ +"$ffmpeg_bin" "$overwrite_flag" -ss 00:00:05 -i "$input" \ -vf "$crop_vf" \ -vframes 1 \ -q:v 2 \ diff --git a/scripts/mkv-to-readme-video.test.ts b/scripts/mkv-to-readme-video.test.ts index 6e56877c..5ff99b09 100644 --- a/scripts/mkv-to-readme-video.test.ts +++ b/scripts/mkv-to-readme-video.test.ts @@ -19,11 +19,33 @@ function writeExecutable(filePath: string, contents: string): void { fs.chmodSync(filePath, 0o755); } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + +function toBashPath(filePath: string): string { + if (process.platform !== 'win32') return filePath; + + const normalized = filePath.replace(/\\/g, '/'); + const match = normalized.match(/^([A-Za-z]):\/(.*)$/); + if (!match) return normalized; + + const drive = match[1]!; + const rest = match[2]!; + const probe = spawnSync('bash', ['-c', 'uname -s'], { encoding: 'utf8' }); + if (probe.status === 0 && /linux/i.test(probe.stdout)) { + return `/mnt/${drive.toLowerCase()}/${rest}`; + } + + return `${drive.toUpperCase()}:/${rest}`; +} + test('mkv-to-readme-video accepts libwebp_anim when libwebp is unavailable', () => { withTempDir((root) => { const binDir = path.join(root, 'bin'); const inputPath = path.join(root, 'sample.mkv'); const ffmpegLogPath = path.join(root, 'ffmpeg-args.log'); + const ffmpegLogPathBash = toBashPath(ffmpegLogPath); fs.mkdirSync(binDir, { recursive: true }); fs.writeFileSync(inputPath, 'fake-video', 'utf8'); @@ -44,22 +66,33 @@ EOF exit 0 fi -printf '%s\\n' "$*" >> "${ffmpegLogPath}" +if [[ "$#" -eq 0 ]]; then + exit 0 +fi + +printf '%s\\n' "$*" >> "${ffmpegLogPathBash}" output="" for arg in "$@"; do output="$arg" done +if [[ -z "$output" ]]; then + exit 0 +fi mkdir -p "$(dirname "$output")" touch "$output" `, ); - const result = spawnSync('bash', ['scripts/mkv-to-readme-video.sh', '--webp', inputPath], { + const ffmpegShimPath = toBashPath(path.join(binDir, 'ffmpeg')); + const ffmpegShimDir = toBashPath(binDir); + const inputBashPath = toBashPath(inputPath); + const command = [ + `chmod +x ${shellQuote(ffmpegShimPath)}`, + `PATH=${shellQuote(`${ffmpegShimDir}:`)}"$PATH"`, + `scripts/mkv-to-readme-video.sh --webp ${shellQuote(inputBashPath)}`, + ].join('; '); + const result = spawnSync('bash', ['-lc', command], { cwd: process.cwd(), - env: { - ...process.env, - PATH: `${binDir}:${process.env.PATH || ''}`, - }, encoding: 'utf8', }); diff --git a/scripts/prepare-build-assets.mjs b/scripts/prepare-build-assets.mjs index 38cad99f..575a2869 100644 --- a/scripts/prepare-build-assets.mjs +++ b/scripts/prepare-build-assets.mjs @@ -8,8 +8,6 @@ const repoRoot = path.resolve(scriptDir, '..'); const rendererSourceDir = path.join(repoRoot, 'src', 'renderer'); const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer'); const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts'); -const windowsHelperSourcePath = path.join(scriptDir, 'get-mpv-window-windows.ps1'); -const windowsHelperOutputPath = path.join(scriptsOutputDir, 'get-mpv-window-windows.ps1'); const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift'); const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos'); const macosHelperSourceCopyPath = path.join(scriptsOutputDir, 'get-mpv-window-macos.swift'); @@ -33,11 +31,6 @@ function copyRendererAssets() { process.stdout.write(`Staged renderer assets in ${rendererOutputDir}\n`); } -function stageWindowsHelper() { - copyFile(windowsHelperSourcePath, windowsHelperOutputPath); - process.stdout.write(`Staged Windows helper: ${windowsHelperOutputPath}\n`); -} - function fallbackToMacosSource() { copyFile(macosHelperSourcePath, macosHelperSourceCopyPath); process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`); @@ -77,7 +70,6 @@ function buildMacosHelper() { function main() { copyRendererAssets(); - stageWindowsHelper(); buildMacosHelper(); } diff --git a/scripts/update-aur-package.sh b/scripts/update-aur-package.sh index fdd62385..5fa6e45f 100755 --- a/scripts/update-aur-package.sh +++ b/scripts/update-aur-package.sh @@ -13,6 +13,26 @@ appimage= wrapper= assets= +normalize_path() { + local value="$1" + if command -v cygpath >/dev/null 2>&1; then + case "$value" in + [A-Za-z]:\\* | [A-Za-z]:/*) + cygpath -u "$value" + return 0 + ;; + esac + fi + if [[ "$value" =~ ^([A-Za-z]):[\\/](.*)$ ]]; then + local drive="${BASH_REMATCH[1],,}" + local rest="${BASH_REMATCH[2]}" + rest="${rest//\\//}" + printf '/mnt/%s/%s\n' "$drive" "$rest" + return 0 + fi + printf '%s\n' "$value" +} + while [[ $# -gt 0 ]]; do case "$1" in --pkg-dir) @@ -53,6 +73,10 @@ if [[ -z "$pkg_dir" || -z "$version" || -z "$appimage" || -z "$wrapper" || -z "$ fi version="${version#v}" +pkg_dir="$(normalize_path "$pkg_dir")" +appimage="$(normalize_path "$appimage")" +wrapper="$(normalize_path "$wrapper")" +assets="$(normalize_path "$assets")" pkgbuild="${pkg_dir}/PKGBUILD" srcinfo="${pkg_dir}/.SRCINFO" @@ -82,6 +106,9 @@ awk \ found_pkgver = 0 found_sha_block = 0 } + { + sub(/\r$/, "") + } /^pkgver=/ { print "pkgver=" version found_pkgver = 1 @@ -140,6 +167,9 @@ awk \ found_source_wrapper = 0 found_source_assets = 0 } + { + sub(/\r$/, "") + } /^\tpkgver = / { print "\tpkgver = " version found_pkgver = 1 diff --git a/scripts/update-aur-package.test.ts b/scripts/update-aur-package.test.ts index b9a52cc8..d8320d64 100644 --- a/scripts/update-aur-package.test.ts +++ b/scripts/update-aur-package.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -9,6 +10,23 @@ function createWorkspace(name: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`)); } +function toBashPath(filePath: string): string { + if (process.platform !== 'win32') return filePath; + + const normalized = filePath.replace(/\\/g, '/'); + const match = normalized.match(/^([A-Za-z]):\/(.*)$/); + if (!match) return normalized; + + const drive = match[1]!; + const rest = match[2]!; + const probe = spawnSync('bash', ['-c', 'uname -s'], { encoding: 'utf8' }); + if (probe.status === 0 && /linux/i.test(probe.stdout)) { + return `/mnt/${drive.toLowerCase()}/${rest}`; + } + + return `${drive.toUpperCase()}:/${rest}`; +} + test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => { const workspace = createWorkspace('subminer-aur-package'); const pkgDir = path.join(workspace, 'aur-subminer-bin'); @@ -29,15 +47,15 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => { [ 'scripts/update-aur-package.sh', '--pkg-dir', - pkgDir, + toBashPath(pkgDir), '--version', 'v0.6.3', '--appimage', - appImagePath, + toBashPath(appImagePath), '--wrapper', - wrapperPath, + toBashPath(wrapperPath), '--assets', - assetsPath, + toBashPath(assetsPath), ], { cwd: process.cwd(), @@ -47,8 +65,8 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => { const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8'); const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8'); - const expectedSums = [appImagePath, wrapperPath, assetsPath].map( - (filePath) => execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0], + const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) => + crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'), ); assert.match(pkgbuild, /^pkgver=0\.6\.3$/m); diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 5d0094da..9df171d0 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -73,6 +73,50 @@ test('parseArgs captures youtube startup forwarding flags', () => { assert.equal(shouldStartApp(args), true); }); +test('parseArgs captures session action forwarding flags', () => { + const args = parseArgs([ + '--toggle-stats-overlay', + '--open-jimaku', + '--open-youtube-picker', + '--open-playlist-browser', + '--replay-current-subtitle', + '--play-next-subtitle', + '--shift-sub-delay-prev-line', + '--shift-sub-delay-next-line', + '--cycle-runtime-option', + 'anki.autoUpdateNewCards:prev', + '--copy-subtitle-count', + '3', + '--mine-sentence-count=2', + ]); + + assert.equal(args.toggleStatsOverlay, true); + assert.equal(args.openJimaku, true); + assert.equal(args.openYoutubePicker, true); + assert.equal(args.openPlaylistBrowser, true); + assert.equal(args.replayCurrentSubtitle, true); + assert.equal(args.playNextSubtitle, true); + assert.equal(args.shiftSubDelayPrevLine, true); + assert.equal(args.shiftSubDelayNextLine, true); + assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards'); + assert.equal(args.cycleRuntimeOptionDirection, -1); + assert.equal(args.copySubtitleCount, 3); + assert.equal(args.mineSentenceCount, 2); + assert.equal(hasExplicitCommand(args), true); + assert.equal(shouldStartApp(args), true); +}); + +test('parseArgs ignores non-positive numeric session action counts', () => { + const args = parseArgs([ + '--copy-subtitle-count=0', + '--mine-sentence-count', + '-1', + ]); + + assert.equal(args.copySubtitleCount, undefined); + assert.equal(args.mineSentenceCount, undefined); +}); + test('youtube playback does not use generic overlay-runtime bootstrap classification', () => { const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']); @@ -172,6 +216,24 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(hasExplicitCommand(anilistRetryQueue), true); assert.equal(shouldStartApp(anilistRetryQueue), false); + const toggleStatsOverlay = parseArgs(['--toggle-stats-overlay']); + assert.equal(toggleStatsOverlay.toggleStatsOverlay, true); + assert.equal(hasExplicitCommand(toggleStatsOverlay), true); + assert.equal(shouldStartApp(toggleStatsOverlay), true); + + const cycleRuntimeOption = parseArgs([ + '--cycle-runtime-option', + 'anki.autoUpdateNewCards:next', + ]); + assert.equal(cycleRuntimeOption.cycleRuntimeOptionId, 'anki.autoUpdateNewCards'); + assert.equal(cycleRuntimeOption.cycleRuntimeOptionDirection, 1); + assert.equal(hasExplicitCommand(cycleRuntimeOption), true); + assert.equal(shouldStartApp(cycleRuntimeOption), true); + assert.equal(commandNeedsOverlayRuntime(cycleRuntimeOption), true); + + const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']); + assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true); + const dictionary = parseArgs(['--dictionary']); assert.equal(dictionary.dictionary, true); assert.equal(hasExplicitCommand(dictionary), true); diff --git a/src/cli/args.ts b/src/cli/args.ts index 49651646..32a3d593 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -24,7 +24,23 @@ export interface CliArgs { triggerFieldGrouping: boolean; triggerSubsync: boolean; markAudioCard: boolean; + toggleStatsOverlay: boolean; + toggleSubtitleSidebar: boolean; openRuntimeOptions: boolean; + openSessionHelp: boolean; + openControllerSelect: boolean; + openControllerDebug: boolean; + openJimaku: boolean; + openYoutubePicker: boolean; + openPlaylistBrowser: boolean; + replayCurrentSubtitle: boolean; + playNextSubtitle: boolean; + shiftSubDelayPrevLine: boolean; + shiftSubDelayNextLine: boolean; + cycleRuntimeOptionId?: string; + cycleRuntimeOptionDirection?: 1 | -1; + copySubtitleCount?: number; + mineSentenceCount?: number; anilistStatus: boolean; anilistLogout: boolean; anilistSetup: boolean; @@ -102,7 +118,19 @@ export function parseArgs(argv: string[]): CliArgs { triggerFieldGrouping: false, triggerSubsync: false, markAudioCard: false, + toggleStatsOverlay: false, + toggleSubtitleSidebar: false, openRuntimeOptions: false, + openSessionHelp: false, + openControllerSelect: false, + openControllerDebug: false, + openJimaku: false, + openYoutubePicker: false, + openPlaylistBrowser: false, + replayCurrentSubtitle: false, + playNextSubtitle: false, + shiftSubDelayPrevLine: false, + shiftSubDelayNextLine: false, anilistStatus: false, anilistLogout: false, anilistSetup: false, @@ -138,6 +166,24 @@ export function parseArgs(argv: string[]): CliArgs { return value; }; + const parseCycleRuntimeOption = ( + value: string | undefined, + ): { id: string; direction: 1 | -1 } | null => { + if (!value) return null; + const separatorIndex = value.lastIndexOf(':'); + if (separatorIndex <= 0 || separatorIndex === value.length - 1) return null; + const id = value.slice(0, separatorIndex).trim(); + const rawDirection = value.slice(separatorIndex + 1).trim().toLowerCase(); + if (!id) return null; + if (rawDirection === 'next' || rawDirection === '1') { + return { id, direction: 1 }; + } + if (rawDirection === 'prev' || rawDirection === '-1') { + return { id, direction: -1 }; + } + return null; + }; + for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg || !arg.startsWith('--')) continue; @@ -179,8 +225,44 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--trigger-field-grouping') args.triggerFieldGrouping = true; else if (arg === '--trigger-subsync') args.triggerSubsync = true; else if (arg === '--mark-audio-card') args.markAudioCard = true; + else if (arg === '--toggle-stats-overlay') args.toggleStatsOverlay = true; + else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true; else if (arg === '--open-runtime-options') args.openRuntimeOptions = true; - else if (arg === '--anilist-status') args.anilistStatus = true; + else if (arg === '--open-session-help') args.openSessionHelp = true; + else if (arg === '--open-controller-select') args.openControllerSelect = true; + else if (arg === '--open-controller-debug') args.openControllerDebug = true; + else if (arg === '--open-jimaku') args.openJimaku = true; + else if (arg === '--open-youtube-picker') args.openYoutubePicker = true; + else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true; + else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true; + else if (arg === '--play-next-subtitle') args.playNextSubtitle = true; + else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true; + else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true; + else if (arg.startsWith('--cycle-runtime-option=')) { + const parsed = parseCycleRuntimeOption(arg.split('=', 2)[1]); + if (parsed) { + args.cycleRuntimeOptionId = parsed.id; + args.cycleRuntimeOptionDirection = parsed.direction; + } + } else if (arg === '--cycle-runtime-option') { + const parsed = parseCycleRuntimeOption(readValue(argv[i + 1])); + if (parsed) { + args.cycleRuntimeOptionId = parsed.id; + args.cycleRuntimeOptionDirection = parsed.direction; + } + } else if (arg.startsWith('--copy-subtitle-count=')) { + const value = Number(arg.split('=', 2)[1]); + if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value; + } else if (arg === '--copy-subtitle-count') { + const value = Number(readValue(argv[i + 1])); + if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value; + } else if (arg.startsWith('--mine-sentence-count=')) { + const value = Number(arg.split('=', 2)[1]); + if (Number.isInteger(value) && value > 0) args.mineSentenceCount = value; + } else if (arg === '--mine-sentence-count') { + const value = Number(readValue(argv[i + 1])); + if (Number.isInteger(value) && value > 0) args.mineSentenceCount = value; + } else if (arg === '--anilist-status') args.anilistStatus = true; else if (arg === '--anilist-logout') args.anilistLogout = true; else if (arg === '--anilist-setup') args.anilistSetup = true; else if (arg === '--anilist-retry-queue') args.anilistRetryQueue = true; @@ -371,7 +453,22 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.triggerFieldGrouping || args.triggerSubsync || args.markAudioCard || + args.toggleStatsOverlay || + args.toggleSubtitleSidebar || args.openRuntimeOptions || + args.openSessionHelp || + args.openControllerSelect || + args.openControllerDebug || + args.openJimaku || + args.openYoutubePicker || + args.openPlaylistBrowser || + args.replayCurrentSubtitle || + args.playNextSubtitle || + args.shiftSubDelayPrevLine || + args.shiftSubDelayNextLine || + args.cycleRuntimeOptionId !== undefined || + args.copySubtitleCount !== undefined || + args.mineSentenceCount !== undefined || args.anilistStatus || args.anilistLogout || args.anilistSetup || @@ -423,7 +520,22 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.triggerFieldGrouping && !args.triggerSubsync && !args.markAudioCard && + !args.toggleStatsOverlay && + !args.toggleSubtitleSidebar && !args.openRuntimeOptions && + !args.openSessionHelp && + !args.openControllerSelect && + !args.openControllerDebug && + !args.openJimaku && + !args.openYoutubePicker && + !args.openPlaylistBrowser && + !args.replayCurrentSubtitle && + !args.playNextSubtitle && + !args.shiftSubDelayPrevLine && + !args.shiftSubDelayNextLine && + args.cycleRuntimeOptionId === undefined && + args.copySubtitleCount === undefined && + args.mineSentenceCount === undefined && !args.anilistStatus && !args.anilistLogout && !args.anilistSetup && @@ -466,7 +578,22 @@ export function shouldStartApp(args: CliArgs): boolean { args.triggerFieldGrouping || args.triggerSubsync || args.markAudioCard || + args.toggleStatsOverlay || + args.toggleSubtitleSidebar || args.openRuntimeOptions || + args.openSessionHelp || + args.openControllerSelect || + args.openControllerDebug || + args.openJimaku || + args.openYoutubePicker || + args.openPlaylistBrowser || + args.replayCurrentSubtitle || + args.playNextSubtitle || + args.shiftSubDelayPrevLine || + args.shiftSubDelayNextLine || + args.cycleRuntimeOptionId !== undefined || + args.copySubtitleCount !== undefined || + args.mineSentenceCount !== undefined || args.dictionary || args.stats || args.jellyfin || @@ -504,7 +631,22 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.triggerFieldGrouping && !args.triggerSubsync && !args.markAudioCard && + !args.toggleStatsOverlay && + !args.toggleSubtitleSidebar && !args.openRuntimeOptions && + !args.openSessionHelp && + !args.openControllerSelect && + !args.openControllerDebug && + !args.openJimaku && + !args.openYoutubePicker && + !args.openPlaylistBrowser && + !args.replayCurrentSubtitle && + !args.playNextSubtitle && + !args.shiftSubDelayPrevLine && + !args.shiftSubDelayNextLine && + args.cycleRuntimeOptionId === undefined && + args.copySubtitleCount === undefined && + args.mineSentenceCount === undefined && !args.anilistStatus && !args.anilistLogout && !args.anilistSetup && @@ -544,10 +686,25 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.mineSentenceMultiple || args.updateLastCardFromClipboard || args.toggleSecondarySub || + args.toggleStatsOverlay || + args.toggleSubtitleSidebar || args.triggerFieldGrouping || args.triggerSubsync || args.markAudioCard || - args.openRuntimeOptions + args.openRuntimeOptions || + args.openSessionHelp || + args.openControllerSelect || + args.openControllerDebug || + args.openJimaku || + args.openYoutubePicker || + args.openPlaylistBrowser || + args.replayCurrentSubtitle || + args.playNextSubtitle || + args.shiftSubDelayPrevLine || + args.shiftSubDelayNextLine || + args.cycleRuntimeOptionId !== undefined || + args.copySubtitleCount !== undefined || + args.mineSentenceCount !== undefined ); } diff --git a/src/cli/help.ts b/src/cli/help.ts index 7b74700b..ad31ff7e 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -35,7 +35,11 @@ ${B}Mining${R} --trigger-field-grouping Run Kiku field grouping --trigger-subsync Run subtitle sync --toggle-secondary-sub Cycle secondary subtitle mode + --toggle-subtitle-sidebar Toggle subtitle sidebar panel --open-runtime-options Open runtime options palette + --open-session-help Open session help modal + --open-controller-select Open controller select modal + --open-controller-debug Open controller debug modal ${B}AniList${R} --anilist-setup Open AniList authentication flow diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 0b7ab18a..85f59272 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -50,6 +50,7 @@ test('loads defaults when config is missing', () => { assert.equal(config.startupWarmups.yomitanExtension, true); assert.equal(config.startupWarmups.subtitleDictionaries, true); assert.equal(config.startupWarmups.jellyfinRemoteSession, true); + assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash'); assert.equal(config.discordPresence.enabled, true); assert.equal(config.discordPresence.updateIntervalMs, 3_000); assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)'); diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 9574b67b..30507bcf 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -88,6 +88,10 @@ export const CORE_DEFAULT_CONFIG: Pick< markAudioCard: 'CommandOrControl+Shift+A', openRuntimeOptions: 'CommandOrControl+Shift+O', openJimaku: 'Ctrl+Shift+J', + openSessionHelp: 'CommandOrControl+Shift+H', + openControllerSelect: 'Alt+C', + openControllerDebug: 'Alt+Shift+C', + toggleSubtitleSidebar: 'Backslash', }, secondarySub: { secondarySubLanguages: [], diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index b75466ff..545d12e9 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -28,7 +28,21 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerFieldGrouping: false, triggerSubsync: false, markAudioCard: false, + toggleStatsOverlay: false, + toggleSubtitleSidebar: false, openRuntimeOptions: false, + openSessionHelp: false, + openControllerSelect: false, + openControllerDebug: false, + openJimaku: false, + openYoutubePicker: false, + openPlaylistBrowser: false, + replayCurrentSubtitle: false, + playNextSubtitle: false, + shiftSubDelayPrevLine: false, + shiftSubDelayNextLine: false, + cycleRuntimeOptionId: undefined, + cycleRuntimeOptionDirection: undefined, anilistStatus: false, anilistLogout: false, anilistSetup: false, diff --git a/src/core/services/app-ready.test.ts b/src/core/services/app-ready.test.ts index f72f7155..a98d359e 100644 --- a/src/core/services/app-ready.test.ts +++ b/src/core/services/app-ready.test.ts @@ -76,9 +76,7 @@ test('runAppReadyRuntime starts websocket in auto mode when plugin missing', asy ); assert.ok(calls.includes('startBackgroundWarmups')); assert.ok( - calls.includes( - 'log:Runtime ready: immersion tracker startup deferred until first media activity.', - ), + calls.includes('log:Runtime ready: immersion tracker startup requested.'), ); }); @@ -103,6 +101,17 @@ test('runAppReadyRuntime starts texthooker on startup when enabled in config', a ); }); +test('runAppReadyRuntime creates immersion tracker during heavy startup', async () => { + const { deps, calls } = makeDeps({ + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + }); + + await runAppReadyRuntime(deps); + + assert.equal(calls.includes('createImmersionTracker'), false); + assert.ok(calls.includes('log:Runtime ready: immersion tracker startup requested.')); +}); + test('runAppReadyRuntime keeps annotation websocket enabled when regular websocket auto-skips', async () => { const { deps, calls } = makeDeps({ getResolvedConfig: () => ({ diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 4ee8482d..2bfed141 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -29,8 +29,22 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerFieldGrouping: false, triggerSubsync: false, markAudioCard: false, + toggleStatsOverlay: false, + toggleSubtitleSidebar: false, refreshKnownWords: false, openRuntimeOptions: false, + openSessionHelp: false, + openControllerSelect: false, + openControllerDebug: false, + openJimaku: false, + openYoutubePicker: false, + openPlaylistBrowser: false, + replayCurrentSubtitle: false, + playNextSubtitle: false, + shiftSubDelayPrevLine: false, + shiftSubDelayNextLine: false, + cycleRuntimeOptionId: undefined, + cycleRuntimeOptionDirection: undefined, anilistStatus: false, anilistLogout: false, anilistSetup: false, @@ -143,6 +157,9 @@ function createDeps(overrides: Partial = {}) { openRuntimeOptionsPalette: () => { calls.push('openRuntimeOptionsPalette'); }, + dispatchSessionAction: async () => { + calls.push('dispatchSessionAction'); + }, getAnilistStatus: () => ({ tokenStatus: 'resolved', tokenSource: 'stored', @@ -499,6 +516,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () => expected: 'startPendingMineSentenceMultiple:2500', }, { args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' }, + { args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' }, { args: { openRuntimeOptions: true }, expected: 'openRuntimeOptionsPalette', @@ -518,6 +536,33 @@ test('handleCliCommand handles visibility and utility command dispatches', () => } }); +test('handleCliCommand dispatches cycle-runtime-option session action', async () => { + let request: unknown = null; + const { deps } = createDeps({ + dispatchSessionAction: async (nextRequest) => { + request = nextRequest; + }, + }); + + handleCliCommand( + makeArgs({ + cycleRuntimeOptionId: 'anki.autoUpdateNewCards', + cycleRuntimeOptionDirection: -1, + }), + 'initial', + deps, + ); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(request, { + actionId: 'cycleRuntimeOption', + payload: { + runtimeOptionId: 'anki.autoUpdateNewCards', + direction: -1, + }, + }); +}); + test('handleCliCommand logs AniList status details', () => { const { deps, calls } = createDeps(); handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index d97be405..cdd61f44 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -1,4 +1,5 @@ import { CliArgs, CliCommandSource, commandNeedsOverlayRuntime } from '../../cli/args'; +import type { SessionActionDispatchRequest } from '../../types/runtime'; export interface CliCommandServiceDeps { setLogLevel?: (level: NonNullable) => void; @@ -32,6 +33,7 @@ export interface CliCommandServiceDeps { triggerSubsyncFromConfig: () => Promise; markLastCardAsAudioCard: () => Promise; openRuntimeOptionsPalette: () => void; + dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise; getAnilistStatus: () => { tokenStatus: 'not_checked' | 'resolved' | 'error'; tokenSource: 'none' | 'literal' | 'stored'; @@ -168,6 +170,7 @@ export interface CliCommandDepsRuntimeOptions { }; ui: UiCliRuntime; app: AppCliRuntime; + dispatchSessionAction: (request: SessionActionDispatchRequest) => Promise; getMultiCopyTimeoutMs: () => number; schedule: (fn: () => void, delayMs: number) => unknown; log: (message: string) => void; @@ -226,6 +229,7 @@ export function createCliCommandDepsRuntime( triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig, markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard, openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette, + dispatchSessionAction: options.dispatchSessionAction, getAnilistStatus: options.anilist.getStatus, clearAnilistToken: options.anilist.clearToken, openAnilistSetup: options.anilist.openSetup, @@ -268,6 +272,19 @@ export function handleCliCommand( source: CliCommandSource = 'initial', deps: CliCommandServiceDeps, ): void { + const dispatchCliSessionAction = ( + request: SessionActionDispatchRequest, + logLabel: string, + osdLabel: string, + ): void => { + runAsyncWithOsd( + () => deps.dispatchSessionAction(request), + deps, + logLabel, + osdLabel, + ); + }; + if (args.logLevel) { deps.setLogLevel?.(args.logLevel); } @@ -379,8 +396,100 @@ export function handleCliCommand( 'markLastCardAsAudioCard', 'Audio card failed', ); + } else if (args.toggleStatsOverlay) { + dispatchCliSessionAction( + { actionId: 'toggleStatsOverlay' }, + 'toggleStatsOverlay', + 'Stats toggle failed', + ); + } else if (args.toggleSubtitleSidebar) { + dispatchCliSessionAction( + { actionId: 'toggleSubtitleSidebar' }, + 'toggleSubtitleSidebar', + 'Subtitle sidebar toggle failed', + ); } else if (args.openRuntimeOptions) { deps.openRuntimeOptionsPalette(); + } else if (args.openSessionHelp) { + dispatchCliSessionAction( + { actionId: 'openSessionHelp' }, + 'openSessionHelp', + 'Open session help failed', + ); + } else if (args.openControllerSelect) { + dispatchCliSessionAction( + { actionId: 'openControllerSelect' }, + 'openControllerSelect', + 'Open controller select failed', + ); + } else if (args.openControllerDebug) { + dispatchCliSessionAction( + { actionId: 'openControllerDebug' }, + 'openControllerDebug', + 'Open controller debug failed', + ); + } else if (args.openJimaku) { + dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed'); + } else if (args.openYoutubePicker) { + dispatchCliSessionAction( + { actionId: 'openYoutubePicker' }, + 'openYoutubePicker', + 'Open YouTube picker failed', + ); + } else if (args.openPlaylistBrowser) { + dispatchCliSessionAction( + { actionId: 'openPlaylistBrowser' }, + 'openPlaylistBrowser', + 'Open playlist browser failed', + ); + } else if (args.replayCurrentSubtitle) { + dispatchCliSessionAction( + { actionId: 'replayCurrentSubtitle' }, + 'replayCurrentSubtitle', + 'Replay subtitle failed', + ); + } else if (args.playNextSubtitle) { + dispatchCliSessionAction( + { actionId: 'playNextSubtitle' }, + 'playNextSubtitle', + 'Play next subtitle failed', + ); + } else if (args.shiftSubDelayPrevLine) { + dispatchCliSessionAction( + { actionId: 'shiftSubDelayPrevLine' }, + 'shiftSubDelayPrevLine', + 'Shift subtitle delay failed', + ); + } else if (args.shiftSubDelayNextLine) { + dispatchCliSessionAction( + { actionId: 'shiftSubDelayNextLine' }, + 'shiftSubDelayNextLine', + 'Shift subtitle delay failed', + ); + } else if (args.cycleRuntimeOptionId !== undefined) { + dispatchCliSessionAction( + { + actionId: 'cycleRuntimeOption', + payload: { + runtimeOptionId: args.cycleRuntimeOptionId, + direction: args.cycleRuntimeOptionDirection ?? 1, + }, + }, + 'cycleRuntimeOption', + 'Runtime option change failed', + ); + } else if (args.copySubtitleCount !== undefined) { + dispatchCliSessionAction( + { actionId: 'copySubtitleMultiple', payload: { count: args.copySubtitleCount } }, + 'copySubtitleMultiple', + 'Copy failed', + ); + } else if (args.mineSentenceCount !== undefined) { + dispatchCliSessionAction( + { actionId: 'mineSentenceMultiple', payload: { count: args.mineSentenceCount } }, + 'mineSentenceMultiple', + 'Mine sentence failed', + ); } else if (args.anilistStatus) { const status = deps.getAnilistStatus(); deps.log(`AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`); diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 3d0e3f70..2c60d9c8 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -72,6 +72,7 @@ export { createOverlayWindow, enforceOverlayLayerOrder, ensureOverlayWindowLevel, + isOverlayWindowContentReady, syncOverlayWindowLayer, updateOverlayWindowBounds, } from './overlay-window'; diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 28ff2d47..847607b6 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -3,7 +3,11 @@ import assert from 'node:assert/strict'; import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; -import type { PlaylistBrowserSnapshot, SubtitleSidebarSnapshot } from '../../types'; +import type { + PlaylistBrowserSnapshot, + SessionActionDispatchRequest, + SubtitleSidebarSnapshot, +} from '../../types'; interface FakeIpcRegistrar { on: Map void>; @@ -127,7 +131,9 @@ function createRegisterIpcDeps(overrides: Partial = {}): IpcServ setMecabEnabled: () => {}, handleMpvCommand: () => {}, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), @@ -226,7 +232,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { getMecabTokenizer: () => null, handleMpvCommand: () => {}, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), @@ -382,7 +390,9 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = setMecabEnabled: () => {}, handleMpvCommand: () => {}, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), @@ -707,7 +717,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { setMecabEnabled: () => {}, handleMpvCommand: () => {}, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), @@ -786,7 +798,9 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon setMecabEnabled: () => {}, handleMpvCommand: () => {}, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), @@ -850,6 +864,79 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon ]); }); +test('registerIpcHandlers validates dispatchSessionAction payloads', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const dispatched: SessionActionDispatchRequest[] = []; + registerIpcHandlers( + createRegisterIpcDeps({ + dispatchSessionAction: async (request) => { + dispatched.push(request); + }, + }), + registrar, + ); + + const dispatchHandler = handlers.handle.get(IPC_CHANNELS.command.dispatchSessionAction); + assert.ok(dispatchHandler); + + await assert.rejects(async () => { + await dispatchHandler!({}, { actionId: 'cycleRuntimeOption', payload: { direction: 1 } }); + }, /Invalid session action payload/); + await assert.rejects(async () => { + await dispatchHandler!({}, { actionId: 'unknown-action' }); + }, /Invalid session action payload/); + + await dispatchHandler!({}, { + actionId: 'copySubtitleMultiple', + payload: { count: 3 }, + }); + await dispatchHandler!({}, { + actionId: 'cycleRuntimeOption', + payload: { + runtimeOptionId: 'anki.autoUpdateNewCards', + direction: -1, + }, + }); + await dispatchHandler!({}, { + actionId: 'toggleSubtitleSidebar', + }); + await dispatchHandler!({}, { + actionId: 'openSessionHelp', + }); + await dispatchHandler!({}, { + actionId: 'openControllerSelect', + }); + await dispatchHandler!({}, { + actionId: 'openControllerDebug', + }); + + assert.deepEqual(dispatched, [ + { + actionId: 'copySubtitleMultiple', + payload: { count: 3 }, + }, + { + actionId: 'cycleRuntimeOption', + payload: { + runtimeOptionId: 'anki.autoUpdateNewCards', + direction: -1, + }, + }, + { + actionId: 'toggleSubtitleSidebar', + }, + { + actionId: 'openSessionHelp', + }, + { + actionId: 'openControllerSelect', + }, + { + actionId: 'openControllerDebug', + }, + ]); +}); + test('registerIpcHandlers rejects malformed controller preference payloads', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); registerIpcHandlers( @@ -872,7 +959,9 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy setMecabEnabled: () => {}, handleMpvCommand: () => {}, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}), + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => createControllerConfigFixture(), diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index ff781457..26f443ca 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -1,6 +1,7 @@ import electron from 'electron'; -import type { IpcMainEvent } from 'electron'; +import type { BrowserWindow as ElectronBrowserWindow, IpcMainEvent } from 'electron'; import type { + CompiledSessionBinding, ControllerConfigUpdate, PlaylistBrowserMutationResult, PlaylistBrowserSnapshot, @@ -12,6 +13,7 @@ import type { SubtitlePosition, SubsyncManualRunRequest, SubsyncResult, + SessionActionDispatchRequest, YoutubePickerResolveRequest, YoutubePickerResolveResult, } from '../../types'; @@ -25,16 +27,23 @@ import { parseRuntimeOptionDirection, parseRuntimeOptionId, parseRuntimeOptionValue, + parseSessionActionDispatchRequest, parseSubtitlePosition, parseSubsyncManualRunRequest, parseYoutubePickerResolveRequest, } from '../../shared/ipc/validators'; -const { BrowserWindow, ipcMain } = electron; +const { ipcMain } = electron; export interface IpcServiceDeps { - onOverlayModalClosed: (modal: OverlayHostedModal) => void; - onOverlayModalOpened?: (modal: OverlayHostedModal) => void; + onOverlayModalClosed: ( + modal: OverlayHostedModal, + senderWindow: ElectronBrowserWindow | null, + ) => void; + onOverlayModalOpened?: ( + modal: OverlayHostedModal, + senderWindow: ElectronBrowserWindow | null, + ) => void; openYomitanSettings: () => void; quitApp: () => void; toggleDevTools: () => void; @@ -56,7 +65,9 @@ export interface IpcServiceDeps { setMecabEnabled: (enabled: boolean) => void; handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; + getSessionBindings?: () => CompiledSessionBinding[]; getConfiguredShortcuts: () => unknown; + dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise; getStatsToggleKey: () => string; getMarkWatchedKey: () => string; getControllerConfig: () => ResolvedControllerConfig; @@ -153,8 +164,14 @@ interface IpcMainRegistrar { export interface IpcDepsRuntimeOptions { getMainWindow: () => WindowLike | null; getVisibleOverlayVisibility: () => boolean; - onOverlayModalClosed: (modal: OverlayHostedModal) => void; - onOverlayModalOpened?: (modal: OverlayHostedModal) => void; + onOverlayModalClosed: ( + modal: OverlayHostedModal, + senderWindow: ElectronBrowserWindow | null, + ) => void; + onOverlayModalOpened?: ( + modal: OverlayHostedModal, + senderWindow: ElectronBrowserWindow | null, + ) => void; openYomitanSettings: () => void; quitApp: () => void; toggleVisibleOverlay: () => void; @@ -169,7 +186,9 @@ export interface IpcDepsRuntimeOptions { getMecabTokenizer: () => MecabTokenizerLike | null; handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; + getSessionBindings?: () => CompiledSessionBinding[]; getConfiguredShortcuts: () => unknown; + dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise; getStatsToggleKey: () => string; getMarkWatchedKey: () => string; getControllerConfig: () => ResolvedControllerConfig; @@ -238,7 +257,9 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService }, handleMpvCommand: options.handleMpvCommand, getKeybindings: options.getKeybindings, + getSessionBindings: options.getSessionBindings ?? (() => []), getConfiguredShortcuts: options.getConfiguredShortcuts, + dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}), getStatsToggleKey: options.getStatsToggleKey, getMarkWatchedKey: options.getMarkWatchedKey, getControllerConfig: options.getControllerConfig, @@ -299,23 +320,28 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar (event: unknown, ignore: unknown, options: unknown = {}) => { if (typeof ignore !== 'boolean') return; const parsedOptions = parseOptionalForwardingOptions(options); - const senderWindow = BrowserWindow.fromWebContents((event as IpcMainEvent).sender); + const senderWindow = + electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; if (senderWindow && !senderWindow.isDestroyed()) { senderWindow.setIgnoreMouseEvents(ignore, parsedOptions); } }, ); - ipc.on(IPC_CHANNELS.command.overlayModalClosed, (_event: unknown, modal: unknown) => { + ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => { const parsedModal = parseOverlayHostedModal(modal); if (!parsedModal) return; - deps.onOverlayModalClosed(parsedModal); + const senderWindow = + electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; + deps.onOverlayModalClosed(parsedModal, senderWindow); }); - ipc.on(IPC_CHANNELS.command.overlayModalOpened, (_event: unknown, modal: unknown) => { + ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => { const parsedModal = parseOverlayHostedModal(modal); if (!parsedModal) return; if (!deps.onOverlayModalOpened) return; - deps.onOverlayModalOpened(parsedModal); + const senderWindow = + electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; + deps.onOverlayModalOpened(parsedModal, senderWindow); }); ipc.handle( @@ -431,10 +457,25 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar deps.handleMpvCommand(parsedCommand); }); + ipc.handle( + IPC_CHANNELS.command.dispatchSessionAction, + async (_event: unknown, request: unknown) => { + const parsedRequest = parseSessionActionDispatchRequest(request); + if (!parsedRequest) { + throw new Error('Invalid session action payload'); + } + await deps.dispatchSessionAction?.(parsedRequest); + }, + ); + ipc.handle(IPC_CHANNELS.request.getKeybindings, () => { return deps.getKeybindings(); }); + ipc.handle(IPC_CHANNELS.request.getSessionBindings, () => { + return deps.getSessionBindings?.() ?? []; + }); + ipc.handle(IPC_CHANNELS.request.getConfigShortcuts, () => { return deps.getConfiguredShortcuts(); }); diff --git a/src/core/services/overlay-drop.test.ts b/src/core/services/overlay-drop.test.ts index a1e0806e..dfc765fa 100644 --- a/src/core/services/overlay-drop.test.ts +++ b/src/core/services/overlay-drop.test.ts @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildMpvLoadfileCommands, + buildMpvSubtitleAddCommands, + collectDroppedSubtitlePaths, collectDroppedVideoPaths, parseClipboardVideoPath, type DropDataTransferLike, @@ -41,6 +43,33 @@ test('collectDroppedVideoPaths parses text/uri-list entries and de-duplicates', assert.deepEqual(result, ['/tmp/ep01.mkv', '/tmp/ep02.webm']); }); +test('collectDroppedSubtitlePaths keeps supported dropped subtitle paths in order', () => { + const transfer = makeTransfer({ + files: [ + { path: '/subs/ep02.ass' }, + { path: '/subs/readme.txt' }, + { path: '/subs/ep03.SRT' }, + ], + }); + + const result = collectDroppedSubtitlePaths(transfer); + + assert.deepEqual(result, ['/subs/ep02.ass', '/subs/ep03.SRT']); +}); + +test('collectDroppedSubtitlePaths parses text/uri-list entries and de-duplicates', () => { + const transfer = makeTransfer({ + getData: (format: string) => + format === 'text/uri-list' + ? '#comment\nfile:///tmp/ep01.ass\nfile:///tmp/ep01.ass\nfile:///tmp/ep02.vtt\nfile:///tmp/readme.md\n' + : '', + }); + + const result = collectDroppedSubtitlePaths(transfer); + + assert.deepEqual(result, ['/tmp/ep01.ass', '/tmp/ep02.vtt']); +}); + test('buildMpvLoadfileCommands replaces first file and appends remainder by default', () => { const commands = buildMpvLoadfileCommands(['/tmp/ep01.mkv', '/tmp/ep02.mkv'], false); @@ -59,6 +88,15 @@ test('buildMpvLoadfileCommands uses append mode when shift-drop is used', () => ]); }); +test('buildMpvSubtitleAddCommands selects first subtitle and adds remainder', () => { + const commands = buildMpvSubtitleAddCommands(['/tmp/ep01.ass', '/tmp/ep02.srt']); + + assert.deepEqual(commands, [ + ['sub-add', '/tmp/ep01.ass', 'select'], + ['sub-add', '/tmp/ep02.srt'], + ]); +}); + test('parseClipboardVideoPath accepts quoted local paths', () => { assert.equal(parseClipboardVideoPath('"/tmp/ep10.mkv"'), '/tmp/ep10.mkv'); }); diff --git a/src/core/services/overlay-drop.ts b/src/core/services/overlay-drop.ts index b899748b..109e43bb 100644 --- a/src/core/services/overlay-drop.ts +++ b/src/core/services/overlay-drop.ts @@ -22,6 +22,8 @@ const VIDEO_EXTENSIONS = new Set([ '.wmv', ]); +const SUBTITLE_EXTENSIONS = new Set(['.ass', '.srt', '.ssa', '.sub', '.vtt']); + function getPathExtension(pathValue: string): string { const normalized = pathValue.split(/[?#]/, 1)[0] ?? ''; const dot = normalized.lastIndexOf('.'); @@ -32,7 +34,11 @@ function isSupportedVideoPath(pathValue: string): boolean { return VIDEO_EXTENSIONS.has(getPathExtension(pathValue)); } -function parseUriList(data: string): string[] { +function isSupportedSubtitlePath(pathValue: string): boolean { + return SUBTITLE_EXTENSIONS.has(getPathExtension(pathValue)); +} + +function parseUriList(data: string, isSupportedPath: (pathValue: string) => boolean): string[] { if (!data.trim()) return []; const out: string[] = []; @@ -47,7 +53,7 @@ function parseUriList(data: string): string[] { if (/^\/[A-Za-z]:\//.test(filePath)) { filePath = filePath.slice(1); } - if (filePath && isSupportedVideoPath(filePath)) { + if (filePath && isSupportedPath(filePath)) { out.push(filePath); } } catch { @@ -87,6 +93,19 @@ export function parseClipboardVideoPath(text: string): string | null { export function collectDroppedVideoPaths( dataTransfer: DropDataTransferLike | null | undefined, +): string[] { + return collectDroppedPaths(dataTransfer, isSupportedVideoPath); +} + +export function collectDroppedSubtitlePaths( + dataTransfer: DropDataTransferLike | null | undefined, +): string[] { + return collectDroppedPaths(dataTransfer, isSupportedSubtitlePath); +} + +function collectDroppedPaths( + dataTransfer: DropDataTransferLike | null | undefined, + isSupportedPath: (pathValue: string) => boolean, ): string[] { if (!dataTransfer) return []; @@ -96,7 +115,7 @@ export function collectDroppedVideoPaths( const addPath = (candidate: string | null | undefined): void => { if (!candidate) return; const trimmed = candidate.trim(); - if (!trimmed || !isSupportedVideoPath(trimmed) || seen.has(trimmed)) return; + if (!trimmed || !isSupportedPath(trimmed) || seen.has(trimmed)) return; seen.add(trimmed); out.push(trimmed); }; @@ -109,7 +128,7 @@ export function collectDroppedVideoPaths( } if (typeof dataTransfer.getData === 'function') { - for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'))) { + for (const pathValue of parseUriList(dataTransfer.getData('text/uri-list'), isSupportedPath)) { addPath(pathValue); } } @@ -130,3 +149,9 @@ export function buildMpvLoadfileCommands( index === 0 ? 'replace' : 'append', ]); } + +export function buildMpvSubtitleAddCommands(paths: string[]): Array<(string | number)[]> { + return paths.map((pathValue, index) => + index === 0 ? ['sub-add', pathValue, 'select'] : ['sub-add', pathValue], + ); +} diff --git a/src/core/services/overlay-runtime-init.test.ts b/src/core/services/overlay-runtime-init.test.ts index 9fd9ad39..cb0d68d9 100644 --- a/src/core/services/overlay-runtime-init.test.ts +++ b/src/core/services/overlay-runtime-init.test.ts @@ -443,3 +443,214 @@ test('initializeOverlayRuntime refreshes visible overlay when tracker focus chan assert.equal(visibilityRefreshCalls, 2); }); + +test('initializeOverlayRuntime refreshes the current subtitle when tracker finds the target window again', () => { + let subtitleRefreshCalls = 0; + const tracker = { + onGeometryChange: null as ((...args: unknown[]) => void) | null, + onWindowFound: null as ((...args: unknown[]) => void) | null, + onWindowLost: null as (() => void) | null, + onWindowFocusChange: null as ((focused: boolean) => void) | null, + start: () => {}, + }; + + initializeOverlayRuntime({ + backendOverride: null, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + isVisibleOverlayVisible: () => true, + updateVisibleOverlayVisibility: () => {}, + refreshCurrentSubtitle: () => { + subtitleRefreshCalls += 1; + }, + getOverlayWindows: () => [], + syncOverlayShortcuts: () => {}, + setWindowTracker: () => {}, + getMpvSocketPath: () => '/tmp/mpv.sock', + createWindowTracker: () => tracker as never, + getResolvedConfig: () => ({ + ankiConnect: { enabled: false } as never, + }), + getSubtitleTimingTracker: () => null, + getMpvClient: () => null, + getRuntimeOptionsManager: () => null, + setAnkiIntegration: () => {}, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + tracker.onWindowFound?.({ x: 100, y: 200, width: 1280, height: 720 }); + + assert.equal(subtitleRefreshCalls, 1); +}); + +test('initializeOverlayRuntime hides overlay windows when tracker loses the target window', () => { + const calls: string[] = []; + const tracker = { + onGeometryChange: null as ((...args: unknown[]) => void) | null, + onWindowFound: null as ((...args: unknown[]) => void) | null, + onWindowLost: null as (() => void) | null, + onWindowFocusChange: null as ((focused: boolean) => void) | null, + isTargetWindowMinimized: () => true, + start: () => {}, + }; + const overlayWindows = [ + { + hide: () => calls.push('hide-visible'), + }, + { + hide: () => calls.push('hide-modal'), + }, + ]; + + initializeOverlayRuntime({ + backendOverride: null, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + isVisibleOverlayVisible: () => true, + updateVisibleOverlayVisibility: () => {}, + refreshCurrentSubtitle: () => {}, + getOverlayWindows: () => overlayWindows as never, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + setWindowTracker: () => {}, + getMpvSocketPath: () => '/tmp/mpv.sock', + createWindowTracker: () => tracker as never, + getResolvedConfig: () => ({ + ankiConnect: { enabled: false } as never, + }), + getSubtitleTimingTracker: () => null, + getMpvClient: () => null, + getRuntimeOptionsManager: () => null, + setAnkiIntegration: () => {}, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + tracker.onWindowLost?.(); + + assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']); +}); + +test('initializeOverlayRuntime hides visible overlay on Windows tracker loss when target is not minimized', () => { + const calls: string[] = []; + const tracker = { + onGeometryChange: null as ((...args: unknown[]) => void) | null, + onWindowFound: null as ((...args: unknown[]) => void) | null, + onWindowLost: null as (() => void) | null, + onWindowFocusChange: null as ((focused: boolean) => void) | null, + isTargetWindowMinimized: () => false, + start: () => {}, + }; + const overlayWindows = [ + { + hide: () => calls.push('hide-visible'), + }, + ]; + + initializeOverlayRuntime({ + backendOverride: null, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: () => {}, + isVisibleOverlayVisible: () => true, + updateVisibleOverlayVisibility: () => { + calls.push('update-visible'); + }, + refreshCurrentSubtitle: () => {}, + getOverlayWindows: () => overlayWindows as never, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + setWindowTracker: () => {}, + getMpvSocketPath: () => '/tmp/mpv.sock', + createWindowTracker: () => tracker as never, + getResolvedConfig: () => ({ + ankiConnect: { enabled: false } as never, + }), + getSubtitleTimingTracker: () => null, + getMpvClient: () => null, + getRuntimeOptionsManager: () => null, + setAnkiIntegration: () => {}, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + calls.length = 0; + tracker.onWindowLost?.(); + + assert.deepEqual(calls, ['hide-visible', 'sync-shortcuts']); +}); + +test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => { + const bounds: Array<{ x: number; y: number; width: number; height: number }> = []; + let visibilityRefreshCalls = 0; + const tracker = { + onGeometryChange: null as ((...args: unknown[]) => void) | null, + onWindowFound: null as ((...args: unknown[]) => void) | null, + onWindowLost: null as (() => void) | null, + onWindowFocusChange: null as ((focused: boolean) => void) | null, + start: () => {}, + }; + + initializeOverlayRuntime({ + backendOverride: null, + createMainWindow: () => {}, + registerGlobalShortcuts: () => {}, + updateVisibleOverlayBounds: (geometry) => { + bounds.push(geometry); + }, + isVisibleOverlayVisible: () => true, + updateVisibleOverlayVisibility: () => { + visibilityRefreshCalls += 1; + }, + refreshCurrentSubtitle: () => {}, + getOverlayWindows: () => [], + syncOverlayShortcuts: () => {}, + setWindowTracker: () => {}, + getMpvSocketPath: () => '/tmp/mpv.sock', + createWindowTracker: () => tracker as never, + getResolvedConfig: () => ({ + ankiConnect: { enabled: false } as never, + }), + getSubtitleTimingTracker: () => null, + getMpvClient: () => null, + getRuntimeOptionsManager: () => null, + setAnkiIntegration: () => {}, + showDesktopNotification: () => {}, + createFieldGroupingCallback: () => async () => ({ + keepNoteId: 1, + deleteNoteId: 2, + deleteDuplicate: false, + cancelled: false, + }), + getKnownWordCacheStatePath: () => '/tmp/known-words-cache.json', + }); + + const restoredGeometry = { x: 100, y: 200, width: 1280, height: 720 }; + tracker.onWindowFound?.(restoredGeometry); + + assert.deepEqual(bounds, [restoredGeometry]); + assert.equal(visibilityRefreshCalls, 2); +}); diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index 85513fea..36ac4d95 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -71,6 +71,7 @@ export function initializeOverlayRuntime(options: { updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; isVisibleOverlayVisible: () => boolean; updateVisibleOverlayVisibility: () => void; + refreshCurrentSubtitle?: () => void; getOverlayWindows: () => BrowserWindow[]; syncOverlayShortcuts: () => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void; @@ -78,6 +79,8 @@ export function initializeOverlayRuntime(options: { override?: string | null, targetMpvSocketPath?: string | null, ) => BaseWindowTracker | null; + bindOverlayOwner?: () => void; + releaseOverlayOwner?: () => void; }): void { options.createMainWindow(); options.registerGlobalShortcuts(); @@ -94,11 +97,14 @@ export function initializeOverlayRuntime(options: { }; windowTracker.onWindowFound = (geometry: WindowGeometry) => { options.updateVisibleOverlayBounds(geometry); + options.bindOverlayOwner?.(); if (options.isVisibleOverlayVisible()) { options.updateVisibleOverlayVisibility(); + options.refreshCurrentSubtitle?.(); } }; windowTracker.onWindowLost = () => { + options.releaseOverlayOwner?.(); for (const window of options.getOverlayWindows()) { window.hide(); } diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts index 8db2f9ac..f2d80b78 100644 --- a/src/core/services/overlay-shortcut-handler.test.ts +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -6,7 +6,11 @@ import { OverlayShortcutRuntimeDeps, runOverlayShortcutLocalFallback, } from './overlay-shortcut-handler'; -import { shouldActivateOverlayShortcuts } from './overlay-shortcut'; +import { + registerOverlayShortcutsRuntime, + shouldActivateOverlayShortcuts, + unregisterOverlayShortcutsRuntime, +} from './overlay-shortcut'; function makeShortcuts(overrides: Partial = {}): ConfiguredShortcuts { return { @@ -23,6 +27,10 @@ function makeShortcuts(overrides: Partial = {}): Configured markAudioCard: null, openRuntimeOptions: null, openJimaku: null, + openSessionHelp: null, + openControllerSelect: null, + openControllerDebug: null, + toggleSubtitleSidebar: null, ...overrides, }; } @@ -313,3 +321,59 @@ test('shouldActivateOverlayShortcuts preserves non-macOS behavior', () => { true, ); }); + +test('registerOverlayShortcutsRuntime reports active shortcuts when configured', () => { + const deps = { + getConfiguredShortcuts: () => makeShortcuts({ openJimaku: 'Ctrl+J' }), + getOverlayHandlers: () => ({ + copySubtitle: () => {}, + copySubtitleMultiple: () => {}, + updateLastCardFromClipboard: () => {}, + triggerFieldGrouping: () => {}, + triggerSubsync: () => {}, + mineSentence: () => {}, + mineSentenceMultiple: () => {}, + toggleSecondarySub: () => {}, + markAudioCard: () => {}, + openRuntimeOptions: () => {}, + openJimaku: () => {}, + }), + cancelPendingMultiCopy: () => {}, + cancelPendingMineSentenceMultiple: () => {}, + }; + + const result = registerOverlayShortcutsRuntime(deps); + assert.equal(result, true); + assert.equal(unregisterOverlayShortcutsRuntime(result, deps), false); +}); + +test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active', () => { + const calls: string[] = []; + const deps = { + getConfiguredShortcuts: () => makeShortcuts({ openJimaku: 'Ctrl+J' }), + getOverlayHandlers: () => ({ + copySubtitle: () => {}, + copySubtitleMultiple: () => {}, + updateLastCardFromClipboard: () => {}, + triggerFieldGrouping: () => {}, + triggerSubsync: () => {}, + mineSentence: () => {}, + mineSentenceMultiple: () => {}, + toggleSecondarySub: () => {}, + markAudioCard: () => {}, + openRuntimeOptions: () => {}, + openJimaku: () => {}, + }), + cancelPendingMultiCopy: () => { + calls.push('cancel-multi-copy'); + }, + cancelPendingMineSentenceMultiple: () => { + calls.push('cancel-mine-sentence-multiple'); + }, + }; + + assert.equal(registerOverlayShortcutsRuntime(deps), true); + const result = unregisterOverlayShortcutsRuntime(true, deps); + assert.equal(result, false); + assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']); +}); diff --git a/src/core/services/overlay-shortcut.test.ts b/src/core/services/overlay-shortcut.test.ts new file mode 100644 index 00000000..72d30e7e --- /dev/null +++ b/src/core/services/overlay-shortcut.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { ConfiguredShortcuts } from '../utils/shortcut-config'; +import { + registerOverlayShortcuts, + syncOverlayShortcutsRuntime, + unregisterOverlayShortcutsRuntime, +} from './overlay-shortcut'; + +function createShortcuts(overrides: Partial = {}): ConfiguredShortcuts { + return { + toggleVisibleOverlayGlobal: null, + copySubtitle: null, + copySubtitleMultiple: null, + updateLastCardFromClipboard: null, + triggerFieldGrouping: null, + triggerSubsync: null, + mineSentence: null, + mineSentenceMultiple: null, + multiCopyTimeoutMs: 2500, + toggleSecondarySub: null, + markAudioCard: null, + openRuntimeOptions: null, + openJimaku: null, + openSessionHelp: null, + openControllerSelect: null, + openControllerDebug: null, + toggleSubtitleSidebar: null, + ...overrides, + }; +} + +test('registerOverlayShortcuts reports active overlay shortcuts when configured', () => { + assert.equal( + registerOverlayShortcuts(createShortcuts({ openJimaku: 'Ctrl+J' }), { + copySubtitle: () => {}, + copySubtitleMultiple: () => {}, + updateLastCardFromClipboard: () => {}, + triggerFieldGrouping: () => {}, + triggerSubsync: () => {}, + mineSentence: () => {}, + mineSentenceMultiple: () => {}, + toggleSecondarySub: () => {}, + markAudioCard: () => {}, + openRuntimeOptions: () => {}, + openJimaku: () => {}, + }), + true, + ); +}); + +test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent', () => { + assert.equal( + registerOverlayShortcuts(createShortcuts(), { + copySubtitle: () => {}, + copySubtitleMultiple: () => {}, + updateLastCardFromClipboard: () => {}, + triggerFieldGrouping: () => {}, + triggerSubsync: () => {}, + mineSentence: () => {}, + mineSentenceMultiple: () => {}, + toggleSecondarySub: () => {}, + markAudioCard: () => {}, + openRuntimeOptions: () => {}, + openJimaku: () => {}, + }), + false, + ); +}); + +test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active', () => { + const calls: string[] = []; + const result = syncOverlayShortcutsRuntime(false, true, { + getConfiguredShortcuts: () => createShortcuts(), + getOverlayHandlers: () => ({ + copySubtitle: () => {}, + copySubtitleMultiple: () => {}, + updateLastCardFromClipboard: () => {}, + triggerFieldGrouping: () => {}, + triggerSubsync: () => {}, + mineSentence: () => {}, + mineSentenceMultiple: () => {}, + toggleSecondarySub: () => {}, + markAudioCard: () => {}, + openRuntimeOptions: () => {}, + openJimaku: () => {}, + }), + cancelPendingMultiCopy: () => { + calls.push('cancel-multi-copy'); + }, + cancelPendingMineSentenceMultiple: () => { + calls.push('cancel-mine-sentence-multiple'); + }, + }); + + assert.equal(result, false); + assert.deepEqual(calls, ['cancel-multi-copy', 'cancel-mine-sentence-multiple']); +}); diff --git a/src/core/services/overlay-shortcut.ts b/src/core/services/overlay-shortcut.ts index 09ea8f10..cfd8375a 100644 --- a/src/core/services/overlay-shortcut.ts +++ b/src/core/services/overlay-shortcut.ts @@ -1,10 +1,4 @@ -import electron from 'electron'; import { ConfiguredShortcuts } from '../utils/shortcut-config'; -import { isGlobalShortcutRegisteredSafe } from './shortcut-fallback'; -import { createLogger } from '../../logger'; - -const { globalShortcut } = electron; -const logger = createLogger('main:overlay-shortcut-service'); export interface OverlayShortcutHandlers { copySubtitle: () => void; @@ -27,6 +21,27 @@ export interface OverlayShortcutLifecycleDeps { cancelPendingMineSentenceMultiple: () => void; } +const OVERLAY_SHORTCUT_KEYS: Array> = [ + 'copySubtitle', + 'copySubtitleMultiple', + 'updateLastCardFromClipboard', + 'triggerFieldGrouping', + 'triggerSubsync', + 'mineSentence', + 'mineSentenceMultiple', + 'toggleSecondarySub', + 'markAudioCard', + 'openRuntimeOptions', + 'openJimaku', +]; + +function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean { + return OVERLAY_SHORTCUT_KEYS.some((key) => { + const shortcut = shortcuts[key]; + return typeof shortcut === 'string' && shortcut.trim().length > 0; + }); +} + export function shouldActivateOverlayShortcuts(args: { overlayRuntimeInitialized: boolean; isMacOSPlatform: boolean; @@ -43,139 +58,12 @@ export function shouldActivateOverlayShortcuts(args: { export function registerOverlayShortcuts( shortcuts: ConfiguredShortcuts, - handlers: OverlayShortcutHandlers, + _handlers: OverlayShortcutHandlers, ): boolean { - let registeredAny = false; - const registerOverlayShortcut = ( - accelerator: string, - handler: () => void, - label: string, - ): void => { - if (isGlobalShortcutRegisteredSafe(accelerator)) { - registeredAny = true; - return; - } - const ok = globalShortcut.register(accelerator, handler); - if (!ok) { - logger.warn(`Failed to register overlay shortcut ${label}: ${accelerator}`); - return; - } - registeredAny = true; - }; - - if (shortcuts.copySubtitleMultiple) { - registerOverlayShortcut( - shortcuts.copySubtitleMultiple, - () => handlers.copySubtitleMultiple(shortcuts.multiCopyTimeoutMs), - 'copySubtitleMultiple', - ); - } - - if (shortcuts.copySubtitle) { - registerOverlayShortcut(shortcuts.copySubtitle, () => handlers.copySubtitle(), 'copySubtitle'); - } - - if (shortcuts.triggerFieldGrouping) { - registerOverlayShortcut( - shortcuts.triggerFieldGrouping, - () => handlers.triggerFieldGrouping(), - 'triggerFieldGrouping', - ); - } - - if (shortcuts.triggerSubsync) { - registerOverlayShortcut( - shortcuts.triggerSubsync, - () => handlers.triggerSubsync(), - 'triggerSubsync', - ); - } - - if (shortcuts.mineSentence) { - registerOverlayShortcut(shortcuts.mineSentence, () => handlers.mineSentence(), 'mineSentence'); - } - - if (shortcuts.mineSentenceMultiple) { - registerOverlayShortcut( - shortcuts.mineSentenceMultiple, - () => handlers.mineSentenceMultiple(shortcuts.multiCopyTimeoutMs), - 'mineSentenceMultiple', - ); - } - - if (shortcuts.toggleSecondarySub) { - registerOverlayShortcut( - shortcuts.toggleSecondarySub, - () => handlers.toggleSecondarySub(), - 'toggleSecondarySub', - ); - } - - if (shortcuts.updateLastCardFromClipboard) { - registerOverlayShortcut( - shortcuts.updateLastCardFromClipboard, - () => handlers.updateLastCardFromClipboard(), - 'updateLastCardFromClipboard', - ); - } - - if (shortcuts.markAudioCard) { - registerOverlayShortcut( - shortcuts.markAudioCard, - () => handlers.markAudioCard(), - 'markAudioCard', - ); - } - - if (shortcuts.openRuntimeOptions) { - registerOverlayShortcut( - shortcuts.openRuntimeOptions, - () => handlers.openRuntimeOptions(), - 'openRuntimeOptions', - ); - } - if (shortcuts.openJimaku) { - registerOverlayShortcut(shortcuts.openJimaku, () => handlers.openJimaku(), 'openJimaku'); - } - - return registeredAny; + return hasConfiguredOverlayShortcuts(shortcuts); } -export function unregisterOverlayShortcuts(shortcuts: ConfiguredShortcuts): void { - if (shortcuts.copySubtitle) { - globalShortcut.unregister(shortcuts.copySubtitle); - } - if (shortcuts.copySubtitleMultiple) { - globalShortcut.unregister(shortcuts.copySubtitleMultiple); - } - if (shortcuts.updateLastCardFromClipboard) { - globalShortcut.unregister(shortcuts.updateLastCardFromClipboard); - } - if (shortcuts.triggerFieldGrouping) { - globalShortcut.unregister(shortcuts.triggerFieldGrouping); - } - if (shortcuts.triggerSubsync) { - globalShortcut.unregister(shortcuts.triggerSubsync); - } - if (shortcuts.mineSentence) { - globalShortcut.unregister(shortcuts.mineSentence); - } - if (shortcuts.mineSentenceMultiple) { - globalShortcut.unregister(shortcuts.mineSentenceMultiple); - } - if (shortcuts.toggleSecondarySub) { - globalShortcut.unregister(shortcuts.toggleSecondarySub); - } - if (shortcuts.markAudioCard) { - globalShortcut.unregister(shortcuts.markAudioCard); - } - if (shortcuts.openRuntimeOptions) { - globalShortcut.unregister(shortcuts.openRuntimeOptions); - } - if (shortcuts.openJimaku) { - globalShortcut.unregister(shortcuts.openJimaku); - } -} +export function unregisterOverlayShortcuts(_shortcuts: ConfiguredShortcuts): void {} export function registerOverlayShortcutsRuntime(deps: OverlayShortcutLifecycleDeps): boolean { return registerOverlayShortcuts(deps.getConfiguredShortcuts(), deps.getOverlayHandlers()); diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index b43d7d79..c265a07b 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -1,32 +1,80 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './overlay-visibility'; type WindowTrackerStub = { isTracking: () => boolean; getGeometry: () => { x: number; y: number; width: number; height: number } | null; + isTargetWindowFocused?: () => boolean; + isTargetWindowMinimized?: () => boolean; }; function createMainWindowRecorder() { const calls: string[] = []; + let visible = false; + let focused = false; + let opacity = 1; + let contentReady = true; const window = { + webContents: {}, isDestroyed: () => false, + isVisible: () => visible, + isFocused: () => focused, hide: () => { + visible = false; + focused = false; calls.push('hide'); }, show: () => { + visible = true; calls.push('show'); }, + showInactive: () => { + visible = true; + calls.push('show-inactive'); + }, focus: () => { + focused = true; calls.push('focus'); }, + setAlwaysOnTop: (flag: boolean) => { + calls.push(`always-on-top:${flag}`); + }, setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`); }, + setOpacity: (nextOpacity: number) => { + opacity = nextOpacity; + calls.push(`opacity:${nextOpacity}`); + }, + moveTop: () => { + calls.push('move-top'); + }, }; + ( + window as { + [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean; + } + )[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady; - return { window, calls }; + return { + window, + calls, + getOpacity: () => opacity, + setContentReady: (nextContentReady: boolean) => { + contentReady = nextContentReady; + ( + window as { + [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean; + } + )[OVERLAY_WINDOW_CONTENT_READY_FLAG] = contentReady; + }, + setFocused: (nextFocused: boolean) => { + focused = nextFocused; + }, + }; } test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => { @@ -163,7 +211,334 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke assert.ok(!calls.includes('osd')); }); -test('Windows visible overlay stays click-through and does not steal focus while tracked', () => { +test('Windows visible overlay stays click-through and binds to mpv while tracked', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(calls.includes('opacity:0')); + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('show-inactive')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('move-top')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(!calls.includes('enforce-order')); + assert.ok(!calls.includes('focus')); +}); + +test('Windows visible overlay restores opacity after the deferred reveal delay', async () => { + const { window, calls, getOpacity } = createMainWindowRecorder(); + let syncWindowsZOrderCalls = 0; + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + syncWindowsZOrderCalls += 1; + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.equal(getOpacity(), 0); + assert.equal(syncWindowsZOrderCalls, 1); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(getOpacity(), 1); + assert.equal(syncWindowsZOrderCalls, 2); + assert.ok(calls.includes('opacity:1')); +}); + +test('Windows visible overlay waits for content-ready before first reveal', () => { + const { window, calls, setContentReady } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + setContentReady(false); + + const run = () => + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + run(); + + assert.ok(!calls.includes('show-inactive')); + assert.ok(!calls.includes('show')); + + setContentReady(true); + run(); + + assert.ok(calls.includes('show-inactive')); +}); + +test('tracked Windows overlay refresh rebinds while already visible', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + calls.length = 0; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('move-top')); + assert.ok(!calls.includes('show')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(calls.includes('sync-shortcuts')); +}); + +test('forced passthrough still reapplies while visible on Windows', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + calls.length = 0; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + forceMousePassthrough: true, + } as never); + + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('move-top')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(!calls.includes('enforce-order')); +}); + +test('forced passthrough still shows tracked overlay while bound to mpv on Windows', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + forceMousePassthrough: true, + } as never); + + assert.ok(calls.includes('show-inactive')); + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('move-top')); + assert.ok(calls.includes('sync-windows-z-order')); +}); + +test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { isTracking: () => true, @@ -191,13 +566,283 @@ test('Windows visible overlay stays click-through and does not steal focus while syncOverlayShortcuts: () => { calls.push('sync-shortcuts'); }, + isMacOSPlatform: true, + isWindowsPlatform: false, + forceMousePassthrough: true, + } as never); + + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('always-on-top:false')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(!calls.includes('enforce-order')); +}); + +test('tracked Windows overlay rebinds without hiding when tracker focus changes', () => { + const { window, calls } = createMainWindowRecorder(); + let focused = true; + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => focused, + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + calls.length = 0; + focused = false; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('move-top')); + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(!calls.includes('enforce-order')); + assert.ok(!calls.includes('show')); +}); + +test('tracked Windows overlay stays interactive while the overlay window itself is focused', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + calls.length = 0; + setFocused(true); + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(calls.includes('mouse-ignore:false:plain')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('move-top')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(!calls.includes('enforce-order')); +}); + +test('tracked Windows overlay reshows click-through even if focus state is stale after a modal closes', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + calls.length = 0; + window.hide(); + calls.length = 0; + setFocused(true); + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, isMacOSPlatform: false, isWindowsPlatform: true, } as never); assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('show')); - assert.ok(!calls.includes('focus')); + assert.ok(calls.includes('show-inactive')); + assert.ok(!calls.includes('show')); +}); + +test('tracked Windows overlay binds above mpv even when tracker focus lags', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('move-top')); + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('ensure-level')); }); test('visible overlay stays hidden while a modal window is active', () => { @@ -355,6 +1000,157 @@ test('Windows keeps visible overlay hidden while tracker is not ready', () => { assert.ok(!calls.includes('update-bounds')); }); +test('Windows preserves visible overlay and rebinds to mpv while tracker transiently loses a non-minimized window', () => { + const { window, calls } = createMainWindowRecorder(); + let tracking = true; + const tracker: WindowTrackerStub = { + isTracking: () => tracking, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + isTargetWindowMinimized: () => false, + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + calls.length = 0; + tracking = false; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(!calls.includes('hide')); + assert.ok(!calls.includes('show')); + assert.ok(!calls.includes('always-on-top:false')); + assert.ok(!calls.includes('move-top')); + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('sync-windows-z-order')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(calls.includes('sync-shortcuts')); +}); + +test('Windows hides the visible overlay when the tracked window is minimized', () => { + const { window, calls } = createMainWindowRecorder(); + let tracking = true; + const tracker: WindowTrackerStub = { + isTracking: () => tracking, + getGeometry: () => (tracking ? { x: 0, y: 0, width: 1280, height: 720 } : null), + isTargetWindowMinimized: () => !tracking, + }; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + calls.length = 0; + tracking = false; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + mainWindow: window as never, + windowTracker: tracker as never, + trackerNotReadyWarningShown: false, + setTrackerNotReadyWarningShown: () => {}, + updateVisibleOverlayBounds: () => { + calls.push('update-bounds'); + }, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + syncWindowsOverlayToMpvZOrder: () => { + calls.push('sync-windows-z-order'); + }, + syncPrimaryOverlayWindowLayer: () => { + calls.push('sync-layer'); + }, + enforceOverlayLayerOrder: () => { + calls.push('enforce-order'); + }, + syncOverlayShortcuts: () => { + calls.push('sync-shortcuts'); + }, + isMacOSPlatform: false, + isWindowsPlatform: true, + } as never); + + assert.ok(calls.includes('hide')); + assert.ok(!calls.includes('sync-windows-z-order')); +}); + test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => { const { window, calls } = createMainWindowRecorder(); let trackerWarning = false; diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index c74e6bbc..f106783e 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -1,6 +1,52 @@ import type { BrowserWindow } from 'electron'; import { BaseWindowTracker } from '../../window-trackers'; import { WindowGeometry } from '../../types'; +import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; + +const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48; +const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap< + BrowserWindow, + ReturnType +>(); +function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void { + const opacityCapableWindow = window as BrowserWindow & { + setOpacity?: (opacity: number) => void; + }; + opacityCapableWindow.setOpacity?.(opacity); +} + +function clearPendingWindowsOverlayReveal(window: BrowserWindow): void { + const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window); + if (!pendingTimeout) { + return; + } + clearTimeout(pendingTimeout); + pendingWindowsOverlayRevealTimeoutByWindow.delete(window); +} + +function scheduleWindowsOverlayReveal( + window: BrowserWindow, + onReveal?: (window: BrowserWindow) => void, +): void { + clearPendingWindowsOverlayReveal(window); + const timeout = setTimeout(() => { + pendingWindowsOverlayRevealTimeoutByWindow.delete(window); + if (window.isDestroyed() || !window.isVisible()) { + return; + } + setOverlayWindowOpacity(window, 1); + onReveal?.(window); + }, WINDOWS_OVERLAY_REVEAL_DELAY_MS); + pendingWindowsOverlayRevealTimeoutByWindow.set(window, timeout); +} + +function isOverlayWindowContentReady(window: BrowserWindow): boolean { + return ( + (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ + OVERLAY_WINDOW_CONTENT_READY_FLAG + ] === true + ); +} export function updateVisibleOverlayVisibility(args: { visibleOverlayVisible: boolean; @@ -8,10 +54,14 @@ export function updateVisibleOverlayVisibility(args: { forceMousePassthrough?: boolean; mainWindow: BrowserWindow | null; windowTracker: BaseWindowTracker | null; + lastKnownWindowsForegroundProcessName?: string | null; + windowsOverlayProcessName?: string | null; + windowsFocusHandoffGraceActive?: boolean; trackerNotReadyWarningShown: boolean; setTrackerNotReadyWarningShown: (shown: boolean) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void; + syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void; syncPrimaryOverlayWindowLayer: (layer: 'visible') => void; enforceOverlayLayerOrder: () => void; syncOverlayShortcuts: () => void; @@ -30,6 +80,10 @@ export function updateVisibleOverlayVisibility(args: { const mainWindow = args.mainWindow; if (args.modalActive) { + if (args.isWindowsPlatform) { + clearPendingWindowsOverlayReveal(mainWindow); + setOverlayWindowOpacity(mainWindow, 0); + } mainWindow.hide(); args.syncOverlayShortcuts(); return; @@ -37,13 +91,93 @@ export function updateVisibleOverlayVisibility(args: { const showPassiveVisibleOverlay = (): void => { const forceMousePassthrough = args.forceMousePassthrough === true; - if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) { + const wasVisible = mainWindow.isVisible(); + const shouldDefaultToPassthrough = + args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough; + const isVisibleOverlayFocused = + typeof mainWindow.isFocused === 'function' && mainWindow.isFocused(); + const windowsForegroundProcessName = + args.lastKnownWindowsForegroundProcessName?.trim().toLowerCase() ?? null; + const windowsOverlayProcessName = args.windowsOverlayProcessName?.trim().toLowerCase() ?? null; + const hasWindowsForegroundProcessSignal = + args.isWindowsPlatform && windowsForegroundProcessName !== null; + const isTrackedWindowsTargetFocused = args.windowTracker?.isTargetWindowFocused?.() ?? true; + const isTrackedWindowsTargetMinimized = + args.isWindowsPlatform && + typeof args.windowTracker?.isTargetWindowMinimized === 'function' && + args.windowTracker.isTargetWindowMinimized(); + const shouldPreserveWindowsOverlayDuringFocusHandoff = + args.isWindowsPlatform && + args.windowsFocusHandoffGraceActive === true && + !!args.windowTracker && + (!hasWindowsForegroundProcessSignal || + windowsForegroundProcessName === 'mpv' || + (windowsOverlayProcessName !== null && + windowsForegroundProcessName === windowsOverlayProcessName)) && + !isTrackedWindowsTargetMinimized && + (args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null); + const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible; + const shouldIgnoreMouseEvents = + forceMousePassthrough || + (shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow)); + const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker; + const shouldKeepTrackedWindowsOverlayTopmost = + !args.isWindowsPlatform || + !args.windowTracker || + isVisibleOverlayFocused || + isTrackedWindowsTargetFocused || + shouldPreserveWindowsOverlayDuringFocusHandoff || + (hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv'); + if (shouldIgnoreMouseEvents) { mainWindow.setIgnoreMouseEvents(true, { forward: true }); } else { mainWindow.setIgnoreMouseEvents(false); } - args.ensureOverlayWindowLevel(mainWindow); - mainWindow.show(); + + if (shouldBindTrackedWindowsOverlay) { + // On Windows, z-order is enforced by the OS via the owner window mechanism + // (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv + // without any manual z-order management. + } else if (!forceMousePassthrough) { + args.ensureOverlayWindowLevel(mainWindow); + } else { + mainWindow.setAlwaysOnTop(false); + } + if (!wasVisible) { + const hasWebContents = + typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object'; + if ( + args.isWindowsPlatform && + hasWebContents && + !isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow) + ) { + // skip — ready-to-show hasn't fired yet; the onWindowContentReady + // callback will trigger another visibility update when the renderer + // has painted its first frame. + } else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) { + setOverlayWindowOpacity(mainWindow, 0); + mainWindow.showInactive(); + mainWindow.setIgnoreMouseEvents(true, { forward: true }); + scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay + ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window) + : undefined); + } else { + if (args.isWindowsPlatform) { + setOverlayWindowOpacity(mainWindow, 0); + } + mainWindow.show(); + if (args.isWindowsPlatform) { + scheduleWindowsOverlayReveal(mainWindow, shouldBindTrackedWindowsOverlay + ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window) + : undefined); + } + } + } + + if (shouldBindTrackedWindowsOverlay) { + args.syncWindowsOverlayToMpvZOrder?.(mainWindow); + } + if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) { mainWindow.focus(); } @@ -63,12 +197,27 @@ export function updateVisibleOverlayVisibility(args: { if (!args.visibleOverlayVisible) { args.setTrackerNotReadyWarningShown(false); args.resetOverlayLoadingOsdSuppression?.(); + if (args.isWindowsPlatform) { + clearPendingWindowsOverlayReveal(mainWindow); + setOverlayWindowOpacity(mainWindow, 0); + } mainWindow.hide(); args.syncOverlayShortcuts(); return; } if (args.windowTracker && args.windowTracker.isTracking()) { + if ( + args.isWindowsPlatform && + typeof args.windowTracker.isTargetWindowMinimized === 'function' && + args.windowTracker.isTargetWindowMinimized() + ) { + clearPendingWindowsOverlayReveal(mainWindow); + setOverlayWindowOpacity(mainWindow, 0); + mainWindow.hide(); + args.syncOverlayShortcuts(); + return; + } args.setTrackerNotReadyWarningShown(false); const geometry = args.windowTracker.getGeometry(); if (geometry) { @@ -76,7 +225,9 @@ export function updateVisibleOverlayVisibility(args: { } args.syncPrimaryOverlayWindowLayer('visible'); showPassiveVisibleOverlay(); - args.enforceOverlayLayerOrder(); + if (!args.forceMousePassthrough && !args.isWindowsPlatform) { + args.enforceOverlayLayerOrder(); + } args.syncOverlayShortcuts(); return; } @@ -87,6 +238,10 @@ export function updateVisibleOverlayVisibility(args: { args.setTrackerNotReadyWarningShown(true); maybeShowOverlayLoadingOsd(); } + if (args.isWindowsPlatform) { + clearPendingWindowsOverlayReveal(mainWindow); + setOverlayWindowOpacity(mainWindow, 0); + } mainWindow.hide(); args.syncOverlayShortcuts(); return; @@ -99,11 +254,32 @@ export function updateVisibleOverlayVisibility(args: { return; } + if ( + args.isWindowsPlatform && + typeof args.windowTracker.isTargetWindowMinimized === 'function' && + !args.windowTracker.isTargetWindowMinimized() && + (mainWindow.isVisible() || args.windowTracker.getGeometry() !== null) + ) { + args.setTrackerNotReadyWarningShown(false); + const geometry = args.windowTracker.getGeometry(); + if (geometry) { + args.updateVisibleOverlayBounds(geometry); + } + args.syncPrimaryOverlayWindowLayer('visible'); + showPassiveVisibleOverlay(); + args.syncOverlayShortcuts(); + return; + } + if (!args.trackerNotReadyWarningShown) { args.setTrackerNotReadyWarningShown(true); maybeShowOverlayLoadingOsd(); } + if (args.isWindowsPlatform) { + clearPendingWindowsOverlayReveal(mainWindow); + setOverlayWindowOpacity(mainWindow, 0); + } mainWindow.hide(); args.syncOverlayShortcuts(); } diff --git a/src/core/services/overlay-window-config.test.ts b/src/core/services/overlay-window-config.test.ts index 5b2a4674..fda73465 100644 --- a/src/core/services/overlay-window-config.test.ts +++ b/src/core/services/overlay-window-config.test.ts @@ -8,7 +8,31 @@ test('overlay window config explicitly disables renderer sandbox for preload com yomitanSession: null, }); + assert.equal(options.backgroundColor, '#00000000'); assert.equal(options.webPreferences?.sandbox, false); + assert.equal(options.webPreferences?.backgroundThrottling, false); +}); + +test('Windows visible overlay window config does not start as always-on-top', () => { + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32', + }); + + try { + const options = buildOverlayWindowOptions('visible', { + isDev: false, + yomitanSession: null, + }); + + assert.equal(options.alwaysOnTop, false); + } finally { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor); + } + } }); test('overlay window config uses the provided Yomitan session when available', () => { diff --git a/src/core/services/overlay-window-flags.ts b/src/core/services/overlay-window-flags.ts new file mode 100644 index 00000000..d8c53372 --- /dev/null +++ b/src/core/services/overlay-window-flags.ts @@ -0,0 +1 @@ +export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady'; diff --git a/src/core/services/overlay-window-input.ts b/src/core/services/overlay-window-input.ts index 0ad8be5d..44f0ab59 100644 --- a/src/core/services/overlay-window-input.ts +++ b/src/core/services/overlay-window-input.ts @@ -66,7 +66,14 @@ export function handleOverlayWindowBlurred(options: { isOverlayVisible: (kind: OverlayWindowKind) => boolean; ensureOverlayWindowLevel: () => void; moveWindowTop: () => void; + onWindowsVisibleOverlayBlur?: () => void; + platform?: NodeJS.Platform; }): boolean { + if ((options.platform ?? process.platform) === 'win32' && options.kind === 'visible') { + options.onWindowsVisibleOverlayBlur?.(); + return false; + } + if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) { return false; } diff --git a/src/core/services/overlay-window-options.ts b/src/core/services/overlay-window-options.ts index 80619b25..146373a3 100644 --- a/src/core/services/overlay-window-options.ts +++ b/src/core/services/overlay-window-options.ts @@ -10,6 +10,7 @@ export function buildOverlayWindowOptions( }, ): BrowserWindowConstructorOptions { const showNativeDebugFrame = process.platform === 'win32' && options.isDev; + const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible'); return { show: false, @@ -18,8 +19,9 @@ export function buildOverlayWindowOptions( x: 0, y: 0, transparent: true, + backgroundColor: '#00000000', frame: false, - alwaysOnTop: true, + alwaysOnTop: shouldStartAlwaysOnTop, skipTaskbar: true, resizable: false, hasShadow: false, @@ -31,6 +33,7 @@ export function buildOverlayWindowOptions( contextIsolation: true, nodeIntegration: false, sandbox: false, + backgroundThrottling: false, webSecurity: true, session: options.yomitanSession ?? undefined, additionalArguments: [`--overlay-layer=${kind}`], diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts index 42e8a77a..4695a52d 100644 --- a/src/core/services/overlay-window.test.ts +++ b/src/core/services/overlay-window.test.ts @@ -103,6 +103,49 @@ test('handleOverlayWindowBlurred skips visible overlay restacking after manual h assert.deepEqual(calls, []); }); +test('handleOverlayWindowBlurred skips Windows visible overlay restacking after focus loss', () => { + const calls: string[] = []; + + const handled = handleOverlayWindowBlurred({ + kind: 'visible', + windowVisible: true, + isOverlayVisible: () => true, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + moveWindowTop: () => { + calls.push('move-top'); + }, + platform: 'win32', + }); + + assert.equal(handled, false); + assert.deepEqual(calls, []); +}); + +test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback without restacking', () => { + const calls: string[] = []; + + const handled = handleOverlayWindowBlurred({ + kind: 'visible', + windowVisible: true, + isOverlayVisible: () => true, + ensureOverlayWindowLevel: () => { + calls.push('ensure-level'); + }, + moveWindowTop: () => { + calls.push('move-top'); + }, + onWindowsVisibleOverlayBlur: () => { + calls.push('windows-visible-blur'); + }, + platform: 'win32', + }); + + assert.equal(handled, false); + assert.deepEqual(calls, ['windows-visible-blur']); +}); + test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => { const calls: string[] = []; @@ -117,6 +160,7 @@ test('handleOverlayWindowBlurred preserves active visible/modal window stacking' moveWindowTop: () => { calls.push('move-visible'); }, + platform: 'linux', }), true, ); diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 6b7b4c6f..29406ef8 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -10,9 +10,24 @@ import { } from './overlay-window-input'; import { buildOverlayWindowOptions } from './overlay-window-options'; import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds'; +import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; +export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags'; const logger = createLogger('main:overlay-window'); const overlayWindowLayerByInstance = new WeakMap(); +const overlayWindowContentReady = new WeakSet(); + +export function isOverlayWindowContentReady(window: BrowserWindow): boolean { + if (window.isDestroyed()) { + return false; + } + return ( + overlayWindowContentReady.has(window) || + (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ + OVERLAY_WINDOW_CONTENT_READY_FLAG + ] === true + ); +} function getOverlayWindowHtmlPath(): string { return path.join(__dirname, '..', '..', 'renderer', 'index.html'); @@ -76,13 +91,20 @@ export function createOverlayWindow( isOverlayVisible: (kind: OverlayWindowKind) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; + onVisibleWindowBlurred?: () => void; + onWindowContentReady?: () => void; onWindowClosed: (kind: OverlayWindowKind) => void; yomitanSession?: Session | null; }, ): BrowserWindow { const window = new BrowserWindow(buildOverlayWindowOptions(kind, options)); + (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ + OVERLAY_WINDOW_CONTENT_READY_FLAG + ] = false; - options.ensureOverlayWindowLevel(window); + if (!(process.platform === 'win32' && kind === 'visible')) { + options.ensureOverlayWindowLevel(window); + } loadOverlayWindowLayer(window, kind); window.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { @@ -93,6 +115,14 @@ export function createOverlayWindow( options.onRuntimeOptionsChanged(); }); + window.once('ready-to-show', () => { + overlayWindowContentReady.add(window); + (window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[ + OVERLAY_WINDOW_CONTENT_READY_FLAG + ] = true; + options.onWindowContentReady?.(); + }); + if (kind === 'visible') { window.webContents.on('devtools-opened', () => { options.setOverlayDebugVisualizationEnabled(true); @@ -136,6 +166,8 @@ export function createOverlayWindow( moveWindowTop: () => { window.moveTop(); }, + onWindowsVisibleOverlayBlur: + kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined, }); }); diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts new file mode 100644 index 00000000..cb162d4f --- /dev/null +++ b/src/core/services/session-actions.ts @@ -0,0 +1,131 @@ +import type { RuntimeOptionApplyResult, RuntimeOptionId } from '../../types'; +import type { SessionActionId } from '../../types/session-bindings'; +import type { SessionActionDispatchRequest } from '../../types/runtime'; + +export interface SessionActionExecutorDeps { + toggleStatsOverlay: () => void; + toggleVisibleOverlay: () => void; + copyCurrentSubtitle: () => void; + copySubtitleCount: (count: number) => void; + updateLastCardFromClipboard: () => Promise; + triggerFieldGrouping: () => Promise; + triggerSubsyncFromConfig: () => Promise; + mineSentenceCard: () => Promise; + mineSentenceCount: (count: number) => void; + toggleSecondarySub: () => void; + toggleSubtitleSidebar: () => void; + markLastCardAsAudioCard: () => Promise; + openRuntimeOptionsPalette: () => void; + openSessionHelp: () => void; + openControllerSelect: () => void; + openControllerDebug: () => void; + openJimaku: () => void; + openYoutubeTrackPicker: () => void | Promise; + openPlaylistBrowser: () => boolean | void | Promise; + replayCurrentSubtitle: () => void; + playNextSubtitle: () => void; + shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise; + cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; + showMpvOsd: (text: string) => void; +} + +function resolveCount(count: number | undefined): number { + const normalized = typeof count === 'number' && Number.isInteger(count) ? count : 1; + return Math.min(9, Math.max(1, normalized)); +} + +function assertUnreachableSessionAction(actionId: never): never { + throw new Error(`Unhandled session action: ${String(actionId)}`); +} + +export async function dispatchSessionAction( + request: SessionActionDispatchRequest, + deps: SessionActionExecutorDeps, +): Promise { + switch (request.actionId) { + case 'toggleStatsOverlay': + deps.toggleStatsOverlay(); + return; + case 'toggleVisibleOverlay': + deps.toggleVisibleOverlay(); + return; + case 'copySubtitle': + deps.copyCurrentSubtitle(); + return; + case 'copySubtitleMultiple': + deps.copySubtitleCount(resolveCount(request.payload?.count)); + return; + case 'updateLastCardFromClipboard': + await deps.updateLastCardFromClipboard(); + return; + case 'triggerFieldGrouping': + await deps.triggerFieldGrouping(); + return; + case 'triggerSubsync': + await deps.triggerSubsyncFromConfig(); + return; + case 'mineSentence': + await deps.mineSentenceCard(); + return; + case 'mineSentenceMultiple': + deps.mineSentenceCount(resolveCount(request.payload?.count)); + return; + case 'toggleSecondarySub': + deps.toggleSecondarySub(); + return; + case 'toggleSubtitleSidebar': + deps.toggleSubtitleSidebar(); + return; + case 'markAudioCard': + await deps.markLastCardAsAudioCard(); + return; + case 'openRuntimeOptions': + deps.openRuntimeOptionsPalette(); + return; + case 'openSessionHelp': + deps.openSessionHelp(); + return; + case 'openControllerSelect': + deps.openControllerSelect(); + return; + case 'openControllerDebug': + deps.openControllerDebug(); + return; + case 'openJimaku': + deps.openJimaku(); + return; + case 'openYoutubePicker': + await deps.openYoutubeTrackPicker(); + return; + case 'openPlaylistBrowser': + await deps.openPlaylistBrowser(); + return; + case 'replayCurrentSubtitle': + deps.replayCurrentSubtitle(); + return; + case 'playNextSubtitle': + deps.playNextSubtitle(); + return; + case 'shiftSubDelayPrevLine': + await deps.shiftSubDelayToAdjacentSubtitle('previous'); + return; + case 'shiftSubDelayNextLine': + await deps.shiftSubDelayToAdjacentSubtitle('next'); + return; + case 'cycleRuntimeOption': { + const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined; + if (!runtimeOptionId) { + deps.showMpvOsd('Runtime option id is required.'); + return; + } + const direction = request.payload?.direction === -1 ? -1 : 1; + const result = deps.cycleRuntimeOption(runtimeOptionId, direction); + if (!result.ok && result.error) { + deps.showMpvOsd(result.error); + } + return; + } + default: + return assertUnreachableSessionAction(request.actionId); + } +} diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts new file mode 100644 index 00000000..41f07251 --- /dev/null +++ b/src/core/services/session-bindings.test.ts @@ -0,0 +1,307 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Keybinding } from '../../types'; +import type { ConfiguredShortcuts } from '../utils/shortcut-config'; +import { SPECIAL_COMMANDS } from '../../config/definitions'; +import { compileSessionBindings } from './session-bindings'; + +function createShortcuts(overrides: Partial = {}): ConfiguredShortcuts { + return { + toggleVisibleOverlayGlobal: null, + copySubtitle: null, + copySubtitleMultiple: null, + updateLastCardFromClipboard: null, + triggerFieldGrouping: null, + triggerSubsync: null, + mineSentence: null, + mineSentenceMultiple: null, + multiCopyTimeoutMs: 2500, + toggleSecondarySub: null, + markAudioCard: null, + openRuntimeOptions: null, + openJimaku: null, + openSessionHelp: null, + openControllerSelect: null, + openControllerDebug: null, + toggleSubtitleSidebar: null, + ...overrides, + }; +} + +function createKeybinding(key: string, command: Keybinding['command']): Keybinding { + return { key, command }; +} + +test('compileSessionBindings merges shortcuts and keybindings into one canonical list', () => { + const result = compileSessionBindings({ + shortcuts: createShortcuts({ + toggleVisibleOverlayGlobal: 'Alt+Shift+O', + openJimaku: 'Ctrl+Shift+J', + openControllerSelect: 'Alt+C', + }), + keybindings: [ + createKeybinding('KeyF', ['cycle', 'fullscreen']), + createKeybinding('Ctrl+Shift+Y', [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]), + ], + platform: 'linux', + }); + + assert.equal(result.warnings.length, 0); + assert.deepEqual( + result.bindings.map((binding) => ({ + actionType: binding.actionType, + sourcePath: binding.sourcePath, + code: binding.key.code, + modifiers: binding.key.modifiers, + target: + binding.actionType === 'session-action' + ? binding.actionId + : binding.command.join(' '), + })), + [ + { + actionType: 'mpv-command', + sourcePath: 'keybindings[0].key', + code: 'KeyF', + modifiers: [], + target: 'cycle fullscreen', + }, + { + actionType: 'session-action', + sourcePath: 'keybindings[1].key', + code: 'KeyY', + modifiers: ['ctrl', 'shift'], + target: 'openYoutubePicker', + }, + { + actionType: 'session-action', + sourcePath: 'shortcuts.openControllerSelect', + code: 'KeyC', + modifiers: ['alt'], + target: 'openControllerSelect', + }, + { + actionType: 'session-action', + sourcePath: 'shortcuts.openJimaku', + code: 'KeyJ', + modifiers: ['ctrl', 'shift'], + target: 'openJimaku', + }, + { + actionType: 'session-action', + sourcePath: 'shortcuts.toggleVisibleOverlayGlobal', + code: 'KeyO', + modifiers: ['alt', 'shift'], + target: 'toggleVisibleOverlay', + }, + ], + ); +}); + +test('compileSessionBindings resolves CommandOrControl per platform', () => { + const input = { + shortcuts: createShortcuts({ + toggleVisibleOverlayGlobal: 'CommandOrControl+Shift+O', + }), + keybindings: [], + }; + + const windows = compileSessionBindings({ ...input, platform: 'win32' }); + const mac = compileSessionBindings({ ...input, platform: 'darwin' }); + + assert.deepEqual(windows.bindings[0]?.key.modifiers, ['ctrl', 'shift']); + assert.deepEqual(mac.bindings[0]?.key.modifiers, ['shift', 'meta']); +}); + +test('compileSessionBindings resolves CommandOrControl in DOM key strings per platform', () => { + const input = { + shortcuts: createShortcuts(), + keybindings: [createKeybinding('CommandOrControl+Shift+J', ['cycle', 'fullscreen'])], + statsToggleKey: 'CommandOrControl+Backquote', + }; + + const windows = compileSessionBindings({ ...input, platform: 'win32' }); + const mac = compileSessionBindings({ ...input, platform: 'darwin' }); + + assert.deepEqual( + windows.bindings + .map((binding) => ({ + sourcePath: binding.sourcePath, + modifiers: binding.key.modifiers, + })) + .sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)), + [ + { + sourcePath: 'keybindings[0].key', + modifiers: ['ctrl', 'shift'], + }, + { + sourcePath: 'stats.toggleKey', + modifiers: ['ctrl'], + }, + ], + ); + + assert.deepEqual( + mac.bindings + .map((binding) => ({ + sourcePath: binding.sourcePath, + modifiers: binding.key.modifiers, + })) + .sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)), + [ + { + sourcePath: 'keybindings[0].key', + modifiers: ['shift', 'meta'], + }, + { + sourcePath: 'stats.toggleKey', + modifiers: ['meta'], + }, + ], + ); +}); + +test('compileSessionBindings drops conflicting bindings that canonicalize to the same key', () => { + const result = compileSessionBindings({ + shortcuts: createShortcuts({ + openJimaku: 'Ctrl+Shift+J', + }), + keybindings: [createKeybinding('Ctrl+Shift+J', [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN])], + platform: 'linux', + }); + + assert.deepEqual(result.bindings, []); + assert.equal(result.warnings.length, 1); + assert.equal(result.warnings[0]?.kind, 'conflict'); + assert.deepEqual(result.warnings[0]?.conflictingPaths, [ + 'shortcuts.openJimaku', + 'keybindings[0].key', + ]); +}); + +test('compileSessionBindings omits disabled bindings', () => { + const result = compileSessionBindings({ + shortcuts: createShortcuts({ + openJimaku: null, + toggleVisibleOverlayGlobal: 'Alt+Shift+O', + }), + keybindings: [createKeybinding('Ctrl+Shift+J', null)], + platform: 'linux', + }); + + assert.equal(result.warnings.length, 0); + assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), [ + 'shortcuts.toggleVisibleOverlayGlobal', + ]); +}); + +test('compileSessionBindings warns on unsupported shortcut and keybinding syntax', () => { + const result = compileSessionBindings({ + shortcuts: createShortcuts({ + openJimaku: 'Hyper+J', + }), + keybindings: [createKeybinding('Ctrl+ß', ['cycle', 'fullscreen'])], + platform: 'linux', + }); + + assert.deepEqual(result.bindings, []); + assert.deepEqual( + result.warnings.map((warning) => `${warning.kind}:${warning.path}`), + ['unsupported:shortcuts.openJimaku', 'unsupported:keybindings[0].key'], + ); +}); + +test('compileSessionBindings rejects malformed command arrays', () => { + const result = compileSessionBindings({ + shortcuts: createShortcuts(), + keybindings: [ + createKeybinding('Ctrl+J', ['show-text', 3000]), + createKeybinding('Ctrl+K', ['show-text', { bad: true } as never] as never), + ], + platform: 'linux', + }); + + assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']); + assert.equal(result.bindings[0]?.actionType, 'mpv-command'); + assert.deepEqual(result.bindings[0]?.command, ['show-text', 3000]); + assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [ + 'unsupported:keybindings[1].command', + ]); +}); + +test('compileSessionBindings rejects non-string command heads and extra args on special commands', () => { + const result = compileSessionBindings({ + shortcuts: createShortcuts(), + keybindings: [ + createKeybinding('Ctrl+J', [42] as never), + createKeybinding('Ctrl+K', [SPECIAL_COMMANDS.JIMAKU_OPEN, 'extra'] as never), + ], + platform: 'linux', + }); + + assert.deepEqual(result.bindings, []); + assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [ + 'unsupported:keybindings[0].command', + 'unsupported:keybindings[1].command', + ]); +}); + +test('compileSessionBindings points unsupported command warnings at the command field', () => { + const result = compileSessionBindings({ + shortcuts: createShortcuts(), + keybindings: [createKeybinding('Ctrl+K', [SPECIAL_COMMANDS.JIMAKU_OPEN, 'extra'] as never)], + platform: 'linux', + }); + + assert.deepEqual(result.bindings, []); + assert.deepEqual(result.warnings.map((warning) => `${warning.kind}:${warning.path}`), [ + 'unsupported:keybindings[0].command', + ]); +}); + +test('compileSessionBindings warns on deprecated toggleVisibleOverlayGlobal config', () => { + const result = compileSessionBindings({ + shortcuts: createShortcuts(), + keybindings: [], + platform: 'linux', + rawConfig: { + shortcuts: { + toggleVisibleOverlayGlobal: 'Alt+Shift+O', + }, + } as never, + }); + + assert.equal(result.bindings.length, 0); + assert.deepEqual(result.warnings, [ + { + kind: 'deprecated-config', + path: 'shortcuts.toggleVisibleOverlayGlobal', + value: 'Alt+Shift+O', + message: 'Rename shortcuts.toggleVisibleOverlayGlobal to shortcuts.toggleVisibleOverlay.', + }, + ]); +}); + +test('compileSessionBindings includes stats toggle in the shared session binding artifact', () => { + const result = compileSessionBindings({ + shortcuts: createShortcuts(), + keybindings: [], + statsToggleKey: 'Backquote', + platform: 'win32', + }); + + assert.equal(result.warnings.length, 0); + assert.deepEqual(result.bindings, [ + { + sourcePath: 'stats.toggleKey', + originalKey: 'Backquote', + key: { + code: 'Backquote', + modifiers: [], + }, + actionType: 'session-action', + actionId: 'toggleStatsOverlay', + }, + ]); +}); diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts new file mode 100644 index 00000000..7b8272f4 --- /dev/null +++ b/src/core/services/session-bindings.ts @@ -0,0 +1,493 @@ +import type { Keybinding, ResolvedConfig } from '../../types'; +import type { ConfiguredShortcuts } from '../utils/shortcut-config'; +import type { + CompiledMpvCommandBinding, + CompiledSessionActionBinding, + CompiledSessionBinding, + PluginSessionBindingsArtifact, + SessionActionId, + SessionBindingWarning, + SessionKeyModifier, + SessionKeySpec, +} from '../../types/session-bindings'; +import { SPECIAL_COMMANDS } from '../../config'; + +type PlatformKeyModel = 'darwin' | 'win32' | 'linux'; + +type CompileSessionBindingsInput = { + keybindings: Keybinding[]; + shortcuts: ConfiguredShortcuts; + statsToggleKey?: string | null; + platform: PlatformKeyModel; + rawConfig?: ResolvedConfig | null; +}; + +type DraftBinding = { + binding: CompiledSessionBinding; + actionFingerprint: string; +}; + +const MODIFIER_ORDER: SessionKeyModifier[] = ['ctrl', 'alt', 'shift', 'meta']; + +const SESSION_SHORTCUT_ACTIONS: Array<{ + key: keyof Omit; + actionId: SessionActionId; +}> = [ + { key: 'toggleVisibleOverlayGlobal', actionId: 'toggleVisibleOverlay' }, + { key: 'copySubtitle', actionId: 'copySubtitle' }, + { key: 'copySubtitleMultiple', actionId: 'copySubtitleMultiple' }, + { key: 'updateLastCardFromClipboard', actionId: 'updateLastCardFromClipboard' }, + { key: 'triggerFieldGrouping', actionId: 'triggerFieldGrouping' }, + { key: 'triggerSubsync', actionId: 'triggerSubsync' }, + { key: 'mineSentence', actionId: 'mineSentence' }, + { key: 'mineSentenceMultiple', actionId: 'mineSentenceMultiple' }, + { key: 'toggleSecondarySub', actionId: 'toggleSecondarySub' }, + { key: 'markAudioCard', actionId: 'markAudioCard' }, + { key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' }, + { key: 'openJimaku', actionId: 'openJimaku' }, + { key: 'openSessionHelp', actionId: 'openSessionHelp' }, + { key: 'openControllerSelect', actionId: 'openControllerSelect' }, + { key: 'openControllerDebug', actionId: 'openControllerDebug' }, + { key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' }, +]; + +function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] { + return [...new Set(modifiers)].sort( + (left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right), + ); +} + +function isValidCommandEntry(value: unknown): value is string | number { + return typeof value === 'string' || typeof value === 'number'; +} + +function normalizeCodeToken(token: string): string | null { + const normalized = token.trim(); + if (!normalized) return null; + if (/^[a-z]$/i.test(normalized)) { + return `Key${normalized.toUpperCase()}`; + } + if (/^[0-9]$/.test(normalized)) { + return `Digit${normalized}`; + } + + const exactMap: Record = { + space: 'Space', + tab: 'Tab', + enter: 'Enter', + return: 'Enter', + esc: 'Escape', + escape: 'Escape', + up: 'ArrowUp', + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', + backspace: 'Backspace', + delete: 'Delete', + slash: 'Slash', + backslash: 'Backslash', + minus: 'Minus', + plus: 'Equal', + equal: 'Equal', + comma: 'Comma', + period: 'Period', + quote: 'Quote', + semicolon: 'Semicolon', + bracketleft: 'BracketLeft', + bracketright: 'BracketRight', + backquote: 'Backquote', + }; + const lower = normalized.toLowerCase(); + if (exactMap[lower]) return exactMap[lower]; + if ( + /^key[a-z]$/i.test(normalized) || + /^digit[0-9]$/i.test(normalized) || + /^arrow(?:up|down|left|right)$/i.test(normalized) || + /^f\d{1,2}$/i.test(normalized) + ) { + const keyMatch = normalized.match(/^key([a-z])$/i); + if (keyMatch) { + return `Key${keyMatch[1]!.toUpperCase()}`; + } + + const digitMatch = normalized.match(/^digit([0-9])$/i); + if (digitMatch) { + return `Digit${digitMatch[1]}`; + } + + const arrowMatch = normalized.match(/^arrow(up|down|left|right)$/i); + if (arrowMatch) { + const direction = arrowMatch[1]!; + return `Arrow${direction[0]!.toUpperCase()}${direction.slice(1).toLowerCase()}`; + } + + const functionKeyMatch = normalized.match(/^f(\d{1,2})$/i); + if (functionKeyMatch) { + return `F${functionKeyMatch[1]}`; + } + } + return null; +} + +function parseAccelerator( + accelerator: string, + platform: PlatformKeyModel, +): { key: SessionKeySpec | null; message?: string } { + const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl'); + if (!normalized) { + return { key: null, message: 'Empty accelerator is not supported.' }; + } + + const parts = normalized.split('+').filter(Boolean); + const keyToken = parts.pop(); + if (!keyToken) { + return { key: null, message: 'Missing accelerator key token.' }; + } + + const modifiers: SessionKeyModifier[] = []; + for (const modifier of parts) { + const lower = modifier.toLowerCase(); + if (lower === 'ctrl' || lower === 'control') { + modifiers.push('ctrl'); + continue; + } + if (lower === 'alt' || lower === 'option') { + modifiers.push('alt'); + continue; + } + if (lower === 'shift') { + modifiers.push('shift'); + continue; + } + if (lower === 'meta' || lower === 'super' || lower === 'command' || lower === 'cmd') { + modifiers.push('meta'); + continue; + } + if (lower === 'commandorcontrol') { + modifiers.push(platform === 'darwin' ? 'meta' : 'ctrl'); + continue; + } + return { + key: null, + message: `Unsupported accelerator modifier: ${modifier}`, + }; + } + + const code = normalizeCodeToken(keyToken); + if (!code) { + return { + key: null, + message: `Unsupported accelerator key token: ${keyToken}`, + }; + } + + return { + key: { + code, + modifiers: normalizeModifiers(modifiers), + }, + }; +} + +function parseDomKeyString( + key: string, + platform: PlatformKeyModel, +): { key: SessionKeySpec | null; message?: string } { + const parts = key + .split('+') + .map((part) => part.trim()) + .filter(Boolean); + const keyToken = parts.pop(); + if (!keyToken) { + return { key: null, message: 'Missing keybinding key token.' }; + } + + const modifiers: SessionKeyModifier[] = []; + for (const modifier of parts) { + const lower = modifier.toLowerCase(); + if (lower === 'ctrl' || lower === 'control') { + modifiers.push('ctrl'); + continue; + } + if (lower === 'alt' || lower === 'option') { + modifiers.push('alt'); + continue; + } + if (lower === 'shift') { + modifiers.push('shift'); + continue; + } + if ( + lower === 'meta' || + lower === 'super' || + lower === 'command' || + lower === 'cmd' || + lower === 'commandorcontrol' + ) { + modifiers.push( + lower === 'commandorcontrol' ? (platform === 'darwin' ? 'meta' : 'ctrl') : 'meta', + ); + continue; + } + return { + key: null, + message: `Unsupported keybinding modifier: ${modifier}`, + }; + } + + const code = normalizeCodeToken(keyToken); + if (!code) { + return { + key: null, + message: `Unsupported keybinding token: ${keyToken}`, + }; + } + + return { + key: { + code, + modifiers: normalizeModifiers(modifiers), + }, + }; +} + +export function getSessionKeySpecSignature(key: SessionKeySpec): string { + return [...key.modifiers, key.code].join('+'); +} + +function resolveCommandBinding( + binding: Keybinding, +): + | Omit + | Omit + | null { + const command = binding.command; + if (!Array.isArray(command) || command.length === 0 || !command.every(isValidCommandEntry)) { + return null; + } + + const first = command[0]; + if (typeof first !== 'string') { + return null; + } + + if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) { + if (command.length !== 1) return null; + return { actionType: 'session-action', actionId: 'triggerSubsync' }; + } + if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) { + if (command.length !== 1) return null; + return { actionType: 'session-action', actionId: 'openRuntimeOptions' }; + } + if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) { + if (command.length !== 1) return null; + return { actionType: 'session-action', actionId: 'openJimaku' }; + } + if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) { + if (command.length !== 1) return null; + return { actionType: 'session-action', actionId: 'openYoutubePicker' }; + } + if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) { + if (command.length !== 1) return null; + return { actionType: 'session-action', actionId: 'openPlaylistBrowser' }; + } + if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) { + if (command.length !== 1) return null; + return { actionType: 'session-action', actionId: 'replayCurrentSubtitle' }; + } + if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) { + if (command.length !== 1) return null; + return { actionType: 'session-action', actionId: 'playNextSubtitle' }; + } + if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) { + if (command.length !== 1) return null; + return { actionType: 'session-action', actionId: 'shiftSubDelayPrevLine' }; + } + if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) { + if (command.length !== 1) return null; + return { actionType: 'session-action', actionId: 'shiftSubDelayNextLine' }; + } + if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { + if (command.length !== 1) { + return null; + } + const parts = first.split(':'); + if (parts.length !== 3) { + return null; + } + const [, runtimeOptionId, rawDirection] = parts; + if (!runtimeOptionId || (rawDirection !== 'prev' && rawDirection !== 'next')) { + return null; + } + return { + actionType: 'session-action', + actionId: 'cycleRuntimeOption', + payload: { + runtimeOptionId, + direction: rawDirection === 'prev' ? -1 : 1, + }, + }; + } + + return { + actionType: 'mpv-command', + command, + }; +} + +function getBindingFingerprint(binding: CompiledSessionBinding): string { + if (binding.actionType === 'mpv-command') { + return `mpv:${JSON.stringify(binding.command)}`; + } + return `session:${binding.actionId}:${JSON.stringify(binding.payload ?? null)}`; +} + +export function compileSessionBindings( + input: CompileSessionBindingsInput, +): { + bindings: CompiledSessionBinding[]; + warnings: SessionBindingWarning[]; +} { + const warnings: SessionBindingWarning[] = []; + const candidates = new Map(); + const legacyToggleVisibleOverlayGlobal = ( + input.rawConfig?.shortcuts as Record | undefined + )?.toggleVisibleOverlayGlobal; + const statsToggleKey = input.statsToggleKey ?? input.rawConfig?.stats?.toggleKey ?? null; + + if (legacyToggleVisibleOverlayGlobal !== undefined) { + warnings.push({ + kind: 'deprecated-config', + path: 'shortcuts.toggleVisibleOverlayGlobal', + value: legacyToggleVisibleOverlayGlobal, + message: 'Rename shortcuts.toggleVisibleOverlayGlobal to shortcuts.toggleVisibleOverlay.', + }); + } + + for (const shortcut of SESSION_SHORTCUT_ACTIONS) { + const accelerator = input.shortcuts[shortcut.key]; + if (!accelerator) continue; + const parsed = parseAccelerator(accelerator, input.platform); + if (!parsed.key) { + warnings.push({ + kind: 'unsupported', + path: `shortcuts.${shortcut.key}`, + value: accelerator, + message: parsed.message ?? 'Unsupported accelerator syntax.', + }); + continue; + } + const binding: CompiledSessionActionBinding = { + sourcePath: `shortcuts.${shortcut.key}`, + originalKey: accelerator, + key: parsed.key, + actionType: 'session-action', + actionId: shortcut.actionId, + }; + const signature = getSessionKeySpecSignature(parsed.key); + const draft = candidates.get(signature) ?? []; + draft.push({ + binding, + actionFingerprint: getBindingFingerprint(binding), + }); + candidates.set(signature, draft); + } + + if (statsToggleKey) { + const parsed = parseDomKeyString(statsToggleKey, input.platform); + if (!parsed.key) { + warnings.push({ + kind: 'unsupported', + path: 'stats.toggleKey', + value: statsToggleKey, + message: parsed.message ?? 'Unsupported stats toggle key syntax.', + }); + } else { + const binding: CompiledSessionActionBinding = { + sourcePath: 'stats.toggleKey', + originalKey: statsToggleKey, + key: parsed.key, + actionType: 'session-action', + actionId: 'toggleStatsOverlay', + }; + const signature = getSessionKeySpecSignature(parsed.key); + const draft = candidates.get(signature) ?? []; + draft.push({ + binding, + actionFingerprint: getBindingFingerprint(binding), + }); + candidates.set(signature, draft); + } + } + + input.keybindings.forEach((binding, index) => { + if (!binding.command) return; + const parsed = parseDomKeyString(binding.key, input.platform); + if (!parsed.key) { + warnings.push({ + kind: 'unsupported', + path: `keybindings[${index}].key`, + value: binding.key, + message: parsed.message ?? 'Unsupported keybinding syntax.', + }); + return; + } + const resolved = resolveCommandBinding(binding); + if (!resolved) { + warnings.push({ + kind: 'unsupported', + path: `keybindings[${index}].command`, + value: binding.command, + message: 'Unsupported keybinding command syntax.', + }); + return; + } + const compiled: CompiledSessionBinding = { + sourcePath: `keybindings[${index}].key`, + originalKey: binding.key, + key: parsed.key, + ...resolved, + }; + const signature = getSessionKeySpecSignature(parsed.key); + const draft = candidates.get(signature) ?? []; + draft.push({ + binding: compiled, + actionFingerprint: getBindingFingerprint(compiled), + }); + candidates.set(signature, draft); + }); + + const bindings: CompiledSessionBinding[] = []; + for (const [signature, draftBindings] of candidates.entries()) { + const uniqueFingerprints = new Set(draftBindings.map((entry) => entry.actionFingerprint)); + if (uniqueFingerprints.size > 1) { + warnings.push({ + kind: 'conflict', + path: draftBindings[0]!.binding.sourcePath, + value: signature, + conflictingPaths: draftBindings.map((entry) => entry.binding.sourcePath), + message: `Conflicting session bindings compile to ${signature}; SubMiner will bind neither action.`, + }); + continue; + } + bindings.push(draftBindings[0]!.binding); + } + + bindings.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)); + return { bindings, warnings }; +} + +export function buildPluginSessionBindingsArtifact(input: { + bindings: CompiledSessionBinding[]; + warnings: SessionBindingWarning[]; + numericSelectionTimeoutMs: number; + now?: Date; +}): PluginSessionBindingsArtifact { + return { + version: 1, + generatedAt: (input.now ?? new Date()).toISOString(), + numericSelectionTimeoutMs: input.numericSelectionTimeoutMs, + bindings: input.bindings, + warnings: input.warnings, + }; +} diff --git a/src/core/services/shortcut.ts b/src/core/services/shortcut.ts index bf88459e..d81766ad 100644 --- a/src/core/services/shortcut.ts +++ b/src/core/services/shortcut.ts @@ -20,42 +20,6 @@ export interface RegisterGlobalShortcutsServiceOptions { } export function registerGlobalShortcuts(options: RegisterGlobalShortcutsServiceOptions): void { - const visibleShortcut = options.shortcuts.toggleVisibleOverlayGlobal; - const normalizedVisible = visibleShortcut?.replace(/\s+/g, '').toLowerCase(); - const normalizedJimaku = options.shortcuts.openJimaku?.replace(/\s+/g, '').toLowerCase(); - const normalizedSettings = 'alt+shift+y'; - - if (visibleShortcut) { - const toggleVisibleRegistered = globalShortcut.register(visibleShortcut, () => { - options.onToggleVisibleOverlay(); - }); - if (!toggleVisibleRegistered) { - logger.warn( - `Failed to register global shortcut toggleVisibleOverlayGlobal: ${visibleShortcut}`, - ); - } - } - - if (options.shortcuts.openJimaku && options.onOpenJimaku) { - if ( - normalizedJimaku && - (normalizedJimaku === normalizedVisible || normalizedJimaku === normalizedSettings) - ) { - logger.warn( - 'Skipped registering openJimaku because it collides with another global shortcut', - ); - } else { - const openJimakuRegistered = globalShortcut.register(options.shortcuts.openJimaku, () => { - options.onOpenJimaku?.(); - }); - if (!openJimakuRegistered) { - logger.warn( - `Failed to register global shortcut openJimaku: ${options.shortcuts.openJimaku}`, - ); - } - } - } - const settingsRegistered = globalShortcut.register('Alt+Shift+Y', () => { options.onOpenYomitanSettings(); }); diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 94b74f1a..188b6c94 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -28,7 +28,21 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerFieldGrouping: false, triggerSubsync: false, markAudioCard: false, + toggleStatsOverlay: false, + toggleSubtitleSidebar: false, openRuntimeOptions: false, + openSessionHelp: false, + openControllerSelect: false, + openControllerDebug: false, + openJimaku: false, + openYoutubePicker: false, + openPlaylistBrowser: false, + replayCurrentSubtitle: false, + playNextSubtitle: false, + shiftSubDelayPrevLine: false, + shiftSubDelayNextLine: false, + cycleRuntimeOptionId: undefined, + cycleRuntimeOptionDirection: undefined, anilistStatus: false, anilistLogout: false, anilistSetup: false, diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index 3b465185..bb563c1d 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -311,7 +311,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise { - sendToActiveOverlayWindow('jimaku:open', undefined, { - restoreOnModalClose: 'jimaku', - }); + openJimakuOverlay(); }, markAudioCard: () => markLastCardAsAudioCard(), copySubtitleMultiple: (timeoutMs: number) => { @@ -1526,6 +1544,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp setKeybindings: (keybindings) => { appState.keybindings = keybindings; }, + setSessionBindings: (sessionBindings, sessionBindingWarnings) => { + persistSessionBindings(sessionBindings, sessionBindingWarnings); + }, refreshGlobalAndOverlayShortcuts: () => { refreshGlobalAndOverlayShortcuts(); }, @@ -1835,6 +1856,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getForceMousePassthrough: () => appState.statsOverlayVisible, getWindowTracker: () => appState.windowTracker, + getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName, + getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), + getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(), getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown: boolean) => { appState.trackerNotReadyWarningShown = shown; @@ -1843,6 +1867,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( ensureOverlayWindowLevel: (window) => { ensureOverlayWindowLevel(window); }, + syncWindowsOverlayToMpvZOrder: (_window) => { + requestWindowsVisibleOverlayZOrderSync(); + }, syncPrimaryOverlayWindowLayer: (layer) => { syncPrimaryOverlayWindowLayer(layer); }, @@ -1870,6 +1897,247 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( }, })(), ); + +const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const; +const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const; +const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75; +const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; +let windowsVisibleOverlayBlurRefreshTimeouts: Array> = []; +let windowsVisibleOverlayZOrderRetryTimeouts: Array> = []; +let windowsVisibleOverlayZOrderSyncInFlight = false; +let windowsVisibleOverlayZOrderSyncQueued = false; +let windowsVisibleOverlayForegroundPollInterval: ReturnType | null = null; +let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; +let lastWindowsVisibleOverlayBlurredAtMs = 0; + +function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void { + for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) { + clearTimeout(timeout); + } + windowsVisibleOverlayBlurRefreshTimeouts = []; +} + +function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void { + for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) { + clearTimeout(timeout); + } + windowsVisibleOverlayZOrderRetryTimeouts = []; +} + +function getWindowsNativeWindowHandle(window: BrowserWindow): string { + const handle = window.getNativeWindowHandle(); + return handle.length >= 8 + ? handle.readBigUInt64LE(0).toString() + : BigInt(handle.readUInt32LE(0)).toString(); +} + +function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number { + const handle = window.getNativeWindowHandle(); + return handle.length >= 8 + ? Number(handle.readBigUInt64LE(0)) + : handle.readUInt32LE(0); +} + +function resolveWindowsOverlayBindTargetHandle(targetMpvSocketPath?: string | null): number | null { + if (process.platform !== 'win32') { + return null; + } + + try { + if (targetMpvSocketPath) { + const windowTracker = appState.windowTracker as + | { + getTargetWindowHandle?: () => number | null; + } + | null; + const trackedHandle = windowTracker?.getTargetWindowHandle?.(); + if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) { + return trackedHandle; + } + } + return findWindowsMpvTargetWindowHandle(); + } catch { + return null; + } +} + +async function syncWindowsVisibleOverlayToMpvZOrder(): Promise { + if (process.platform !== 'win32') { + return false; + } + + const mainWindow = overlayManager.getMainWindow(); + if ( + !mainWindow || + mainWindow.isDestroyed() || + !mainWindow.isVisible() || + !overlayManager.getVisibleOverlayVisible() + ) { + return false; + } + + const windowTracker = appState.windowTracker; + if (!windowTracker) { + return false; + } + + if ( + typeof windowTracker.isTargetWindowMinimized === 'function' && + windowTracker.isTargetWindowMinimized() + ) { + return false; + } + + if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) { + return false; + } + + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath); + if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) { + (mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1); + return true; + } + return false; +} + +function requestWindowsVisibleOverlayZOrderSync(): void { + if (process.platform !== 'win32') { + return; + } + + if (windowsVisibleOverlayZOrderSyncInFlight) { + windowsVisibleOverlayZOrderSyncQueued = true; + return; + } + + windowsVisibleOverlayZOrderSyncInFlight = true; + void syncWindowsVisibleOverlayToMpvZOrder() + .catch((error) => { + logger.warn('Failed to bind Windows overlay z-order to mpv', error); + }) + .finally(() => { + windowsVisibleOverlayZOrderSyncInFlight = false; + if (!windowsVisibleOverlayZOrderSyncQueued) { + return; + } + + windowsVisibleOverlayZOrderSyncQueued = false; + requestWindowsVisibleOverlayZOrderSync(); + }); +} + +function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void { + if (process.platform !== 'win32') { + return; + } + + clearWindowsVisibleOverlayZOrderRetryTimeouts(); + for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) { + const retryTimeout = setTimeout(() => { + windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter( + (timeout) => timeout !== retryTimeout, + ); + requestWindowsVisibleOverlayZOrderSync(); + }, delayMs); + windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout); + } +} + +function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean { + return ( + process.platform === 'win32' && + lastWindowsVisibleOverlayBlurredAtMs > 0 && + Date.now() - lastWindowsVisibleOverlayBlurredAtMs <= + WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS + ); +} + +function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean { + if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) { + return false; + } + + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + return false; + } + + const windowTracker = appState.windowTracker; + if (!windowTracker) { + return false; + } + + if ( + typeof windowTracker.isTargetWindowMinimized === 'function' && + windowTracker.isTargetWindowMinimized() + ) { + return false; + } + + const overlayFocused = mainWindow.isFocused(); + const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false; + return !overlayFocused && !trackerFocused; +} + +function maybePollWindowsVisibleOverlayForegroundProcess(): void { + if (!shouldPollWindowsVisibleOverlayForegroundProcess()) { + lastWindowsVisibleOverlayForegroundProcessName = null; + return; + } + + const processName = getWindowsForegroundProcessName(); + const normalizedProcessName = processName?.trim().toLowerCase() ?? null; + const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName; + lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName; + + if (normalizedProcessName !== previousProcessName) { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + } + if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') { + requestWindowsVisibleOverlayZOrderSync(); + } +} + +function ensureWindowsVisibleOverlayForegroundPollLoop(): void { + if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) { + return; + } + + windowsVisibleOverlayForegroundPollInterval = setInterval(() => { + maybePollWindowsVisibleOverlayForegroundProcess(); + }, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS); +} + +function clearWindowsVisibleOverlayForegroundPollLoop(): void { + if (windowsVisibleOverlayForegroundPollInterval === null) { + return; + } + + clearInterval(windowsVisibleOverlayForegroundPollInterval); + windowsVisibleOverlayForegroundPollInterval = null; +} + +function scheduleVisibleOverlayBlurRefresh(): void { + if (process.platform !== 'win32') { + return; + } + + lastWindowsVisibleOverlayBlurredAtMs = Date.now(); + clearWindowsVisibleOverlayBlurRefreshTimeouts(); + for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { + const refreshTimeout = setTimeout(() => { + windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter( + (timeout) => timeout !== refreshTimeout, + ); + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, delayMs); + windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout); + } +} + +ensureWindowsVisibleOverlayForegroundPollLoop(); + const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler( { getRuntimeOptionsManager: () => appState.runtimeOptionsManager, @@ -1957,8 +2225,84 @@ function setOverlayDebugVisualizationEnabled(enabled: boolean): void { overlayVisibilityComposer.setOverlayDebugVisualizationEnabled(enabled); } +function createOverlayHostedModalOpenDeps(): { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +} { + return { + ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), + ensureOverlayWindowsReadyForVisibilityActions: () => + ensureOverlayWindowsReadyForVisibilityActions(), + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => + sendToActiveOverlayWindow(channel, payload, runtimeOptions), + waitForModalOpen: (modal, timeoutMs) => overlayModalRuntime.waitForModalOpen(modal, timeoutMs), + logWarn: (message) => logger.warn(message), + }; +} + +function openOverlayHostedModalWithOsd( + openModal: (deps: ReturnType) => Promise, + unavailableMessage: string, + failureLogMessage: string, +): void { + void openModal(createOverlayHostedModalOpenDeps()).then((opened) => { + if (!opened) { + showMpvOsd(unavailableMessage); + } + }).catch((error) => { + logger.error(failureLogMessage, error); + showMpvOsd(unavailableMessage); + }); +} + function openRuntimeOptionsPalette(): void { - overlayVisibilityComposer.openRuntimeOptionsPalette(); + openOverlayHostedModalWithOsd( + openRuntimeOptionsModalRuntime, + 'Runtime options overlay unavailable.', + 'Failed to open runtime options overlay.', + ); +} + +function openJimakuOverlay(): void { + openOverlayHostedModalWithOsd( + openJimakuModalRuntime, + 'Jimaku overlay unavailable.', + 'Failed to open Jimaku overlay.', + ); +} + +function openSessionHelpOverlay(): void { + openOverlayHostedModalWithOsd( + openSessionHelpModalRuntime, + 'Session help overlay unavailable.', + 'Failed to open session help overlay.', + ); +} + +function openControllerSelectOverlay(): void { + openOverlayHostedModalWithOsd( + openControllerSelectModalRuntime, + 'Controller select overlay unavailable.', + 'Failed to open controller select overlay.', + ); +} + +function openControllerDebugOverlay(): void { + openOverlayHostedModalWithOsd( + openControllerDebugModalRuntime, + 'Controller debug overlay unavailable.', + 'Failed to open controller debug overlay.', + ); } function openPlaylistBrowser(): void { @@ -1966,16 +2310,11 @@ function openPlaylistBrowser(): void { showMpvOsd('Playlist browser requires active playback.'); return; } - const opened = openPlaylistBrowserRuntime({ - ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(), - ensureOverlayWindowsReadyForVisibilityActions: () => - ensureOverlayWindowsReadyForVisibilityActions(), - sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => - sendToActiveOverlayWindow(channel, payload, runtimeOptions), - }); - if (!opened) { - showMpvOsd('Playlist browser overlay unavailable.'); - } + openOverlayHostedModalWithOsd( + openPlaylistBrowserRuntime, + 'Playlist browser overlay unavailable.', + 'Failed to open playlist browser overlay.', + ); } function getResolvedConfig() { @@ -2746,6 +3085,8 @@ const { annotationSubtitleWsService.stop(); }, stopTexthookerService: () => texthookerService.stop(), + clearWindowsVisibleOverlayForegroundPollLoop: () => + clearWindowsVisibleOverlayForegroundPollLoop(), getMainOverlayWindow: () => overlayManager.getMainWindow(), clearMainOverlayWindow: () => overlayManager.setMainWindow(null), getModalOverlayWindow: () => overlayManager.getModalWindow(), @@ -3146,6 +3487,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ loadSubtitlePosition: () => loadSubtitlePosition(), resolveKeybindings: () => { appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); + refreshCurrentSessionBindings(); }, createMpvClient: () => { appState.mpvClient = createMpvClientRuntimeService(); @@ -3288,6 +3630,9 @@ function ensureOverlayStartupPrereqs(): void { } if (appState.keybindings.length === 0) { appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS); + refreshCurrentSessionBindings(); + } else if (!appState.sessionBindingsInitialized) { + refreshCurrentSessionBindings(); } if (!appState.mpvClient) { appState.mpvClient = createMpvClientRuntimeService(); @@ -3674,6 +4019,12 @@ function applyOverlayRegions(geometry: WindowGeometry): void { const buildUpdateVisibleOverlayBoundsMainDepsHandler = createBuildUpdateVisibleOverlayBoundsMainDepsHandler({ setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry), + afterSetOverlayWindowBounds: () => { + if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) { + return; + } + scheduleWindowsVisibleOverlayZOrderSyncBurst(); + }, }); const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler(); const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler( @@ -3796,7 +4147,14 @@ function createModalWindow(): BrowserWindow { } function createMainWindow(): BrowserWindow { - return createMainWindowHandler(); + const window = createMainWindowHandler(); + if (process.platform === 'win32') { + const overlayHwnd = getWindowsNativeWindowHandleNumber(window); + if (!ensureWindowsOverlayTransparency(overlayHwnd)) { + logger.warn('Failed to eagerly extend Windows overlay transparency via koffi'); + } + } + return window; } function ensureTray(): void { @@ -3873,6 +4231,53 @@ const { }, }); +function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' { + if (process.platform === 'darwin') return 'darwin'; + if (process.platform === 'win32') return 'win32'; + return 'linux'; +} + +function compileCurrentSessionBindings(): { + bindings: CompiledSessionBinding[]; + warnings: ReturnType['warnings']; +} { + return compileSessionBindings({ + keybindings: appState.keybindings, + shortcuts: getConfiguredShortcuts(), + statsToggleKey: getResolvedConfig().stats.toggleKey, + platform: resolveSessionBindingPlatform(), + rawConfig: getResolvedConfig(), + }); +} + +function persistSessionBindings( + bindings: CompiledSessionBinding[], + warnings: ReturnType['warnings'] = [], +): void { + const artifact = buildPluginSessionBindingsArtifact({ + bindings, + warnings, + numericSelectionTimeoutMs: getConfiguredShortcuts().multiCopyTimeoutMs, + }); + writeSessionBindingsArtifact(CONFIG_DIR, artifact); + appState.sessionBindings = bindings; + appState.sessionBindingsInitialized = true; + if (appState.mpvClient?.connected) { + sendMpvCommandRuntime(appState.mpvClient, [ + 'script-message', + 'subminer-reload-session-bindings', + ]); + } +} + +function refreshCurrentSessionBindings(): void { + const compiled = compileCurrentSessionBindings(); + for (const warning of compiled.warnings) { + logger.warn(`[session-bindings] ${warning.message}`); + } + persistSessionBindings(compiled.bindings, compiled.warnings); +} + const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ appendToMpvLogMainDeps: { logPath: DEFAULT_MPV_LOG_PATH, @@ -3923,6 +4328,10 @@ function handleCycleSecondarySubMode(): void { cycleSecondarySubMode(); } +function toggleSubtitleSidebar(): void { + broadcastToOverlayWindows(IPC_CHANNELS.event.subtitleSidebarToggle); +} + async function triggerSubsyncFromConfig(): Promise { await subsyncRuntime.triggerFromConfig(); } @@ -4184,6 +4593,55 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen showMpvOsd: (text) => showMpvOsd(text), }); +async function dispatchSessionAction(request: SessionActionDispatchRequest): Promise { + await dispatchSessionActionCore(request, { + toggleStatsOverlay: () => + toggleStatsOverlayWindow({ + staticDir: statsDistPath, + preloadPath: statsPreloadPath, + getApiBaseUrl: () => ensureStatsServerStarted(), + getToggleKey: () => getResolvedConfig().stats.toggleKey, + resolveBounds: () => getCurrentOverlayGeometry(), + onVisibilityChanged: (visible) => { + appState.statsOverlayVisible = visible; + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, + }), + toggleVisibleOverlay: () => toggleVisibleOverlay(), + copyCurrentSubtitle: () => copyCurrentSubtitle(), + copySubtitleCount: (count) => handleMultiCopyDigit(count), + updateLastCardFromClipboard: () => updateLastCardFromClipboard(), + triggerFieldGrouping: () => triggerFieldGrouping(), + triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), + mineSentenceCard: () => mineSentenceCard(), + mineSentenceCount: (count) => handleMineSentenceDigit(count), + toggleSecondarySub: () => handleCycleSecondarySubMode(), + toggleSubtitleSidebar: () => toggleSubtitleSidebar(), + markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), + openJimaku: () => openJimakuOverlay(), + openSessionHelp: () => openSessionHelpOverlay(), + openControllerSelect: () => openControllerSelectOverlay(), + openControllerDebug: () => openControllerDebugOverlay(), + openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), + openPlaylistBrowser: () => openPlaylistBrowser(), + replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), + playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient), + shiftSubDelayToAdjacentSubtitle: (direction) => + shiftSubtitleDelayToAdjacentCueHandler(direction), + cycleRuntimeOption: (id, direction) => { + if (!appState.runtimeOptionsManager) { + return { ok: false, error: 'Runtime options manager unavailable' }; + } + return applyRuntimeOptionResultRuntime( + appState.runtimeOptionsManager.cycleOption(id, direction), + (text) => showMpvOsd(text), + ); + }, + showMpvOsd: (text) => showMpvOsd(text), + }); +} + const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, { getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, @@ -4193,7 +4651,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ mpvCommandMainDeps: { triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), - openJimaku: () => overlayModalRuntime.openJimaku(), + openJimaku: () => openJimakuOverlay(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), openPlaylistBrowser: () => openPlaylistBrowser(), cycleRuntimeOption: (id, direction) => { @@ -4233,7 +4691,17 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ mainWindow.focus(); } }, - onOverlayModalClosed: (modal) => { + onOverlayModalClosed: (modal, senderWindow) => { + const modalWindow = overlayManager.getModalWindow(); + if ( + senderWindow && + modalWindow && + senderWindow === modalWindow && + !senderWindow.isDestroyed() + ) { + senderWindow.setIgnoreMouseEvents(true, { forward: true }); + senderWindow.hide(); + } handleOverlayModalClosed(modal); }, onOverlayModalOpened: (modal) => { @@ -4341,7 +4809,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ saveSubtitlePosition: (position) => saveSubtitlePosition(position), getMecabTokenizer: () => appState.mecabTokenizer, getKeybindings: () => appState.keybindings, + getSessionBindings: () => appState.sessionBindings, getConfiguredShortcuts: () => getConfiguredShortcuts(), + dispatchSessionAction: (request) => dispatchSessionAction(request), getStatsToggleKey: () => getResolvedConfig().stats.toggleKey, getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey, getControllerConfig: () => getResolvedConfig().controller, @@ -4462,6 +4932,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({ printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT), stopApp: () => requestAppQuit(), hasMainWindow: () => Boolean(overlayManager.getMainWindow()), + dispatchSessionAction: (request: SessionActionDispatchRequest) => dispatchSessionAction(request), getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), logInfo: (message: string) => logger.info(message), @@ -4595,6 +5066,8 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa tryHandleOverlayShortcutLocalFallback: (input) => overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input), forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']), + onVisibleWindowBlurred: () => scheduleVisibleOverlayBlurRefresh(), + onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), onWindowClosed: (windowKind) => { if (windowKind === 'visible') { overlayManager.setMainWindow(null); @@ -4696,6 +5169,9 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = updateVisibleOverlayVisibility: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(), }, + refreshCurrentSubtitle: () => { + subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); + }, overlayShortcutsRuntime: { syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(), }, @@ -4719,6 +5195,40 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = }, updateVisibleOverlayBounds: (geometry: WindowGeometry) => updateVisibleOverlayBounds(geometry), + bindOverlayOwner: () => { + const mainWindow = overlayManager.getMainWindow(); + if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(appState.mpvSocketPath); + if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) { + return; + } + const tracker = appState.windowTracker; + const mpvResult = tracker + ? (() => { + try { + const win32 = require('./window-trackers/win32') as typeof import('./window-trackers/win32'); + const poll = win32.findMpvWindows(); + const focused = poll.matches.find((m) => m.isForeground); + return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null; + } catch { + return null; + } + })() + : null; + if (!mpvResult) return; + if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) { + logger.warn('Failed to set overlay owner via koffi'); + } + }, + releaseOverlayOwner: () => { + const mainWindow = overlayManager.getMainWindow(); + if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return; + const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow); + if (!clearWindowsOverlayOwner(overlayHwnd)) { + logger.warn('Failed to clear overlay owner via koffi'); + } + }, getOverlayWindows: () => getOverlayWindows(), getResolvedConfig: () => getResolvedConfig(), showDesktopNotification, diff --git a/src/main/boot/services.test.ts b/src/main/boot/services.test.ts index c469c8b4..f318dc0e 100644 --- a/src/main/boot/services.test.ts +++ b/src/main/boot/services.test.ts @@ -23,7 +23,7 @@ test('createMainBootServices builds boot-phase service bundle', () => { { kind: string }, { scope: string; warn: () => void; info: () => void; error: () => void }, { registry: boolean }, - { getModalWindow: () => null }, + { getMainWindow: () => null; getModalWindow: () => null }, { inputState: boolean; getModalInputExclusive: () => boolean; @@ -82,6 +82,7 @@ test('createMainBootServices builds boot-phase service bundle', () => { }) as const, createMainRuntimeRegistry: () => ({ registry: true }), createOverlayManager: () => ({ + getMainWindow: () => null, getModalWindow: () => null, }), createOverlayModalInputState: () => ({ diff --git a/src/main/boot/services.ts b/src/main/boot/services.ts index 51c4f74f..f4f46582 100644 --- a/src/main/boot/services.ts +++ b/src/main/boot/services.ts @@ -74,6 +74,7 @@ export interface MainBootServicesParams< getModalWindow: () => BrowserWindow | null; syncOverlayShortcutsForModal: (isActive: boolean) => void; syncOverlayVisibilityForModal: () => void; + restoreMainWindowFocus?: () => void; }) => TOverlayModalInputState; createOverlayContentMeasurementStore: (params: { logger: TLogger; @@ -131,7 +132,7 @@ export function createMainBootServices< TSubtitleWebSocket, TLogger, TRuntimeRegistry, - TOverlayManager extends { getModalWindow: () => BrowserWindow | null }, + TOverlayManager extends { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null }, TOverlayModalInputState extends OverlayModalInputStateShape, TOverlayContentMeasurementStore, TOverlayModalRuntime, @@ -212,6 +213,26 @@ export function createMainBootServices< syncOverlayVisibilityForModal: () => { params.getSyncOverlayVisibilityForModal()(); }, + restoreMainWindowFocus: () => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) return; + try { + const electron = require('electron') as { + app?: { focus?: (options?: { steal?: boolean }) => void }; + }; + electron.app?.focus?.({ steal: true }); + } catch { + // Ignore in non-Electron environments. + } + const maybeFocusable = mainWindow as typeof mainWindow & { + setFocusable?: (focusable: boolean) => void; + }; + maybeFocusable.setFocusable?.(true); + mainWindow.focus(); + if (!mainWindow.webContents.isFocused()) { + mainWindow.webContents.focus(); + } + }, }); const overlayContentMeasurementStore = params.createOverlayContentMeasurementStore({ logger, diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 608cd47a..4cec9dc0 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -30,6 +30,7 @@ export interface CliCommandRuntimeServiceContext { triggerFieldGrouping: () => Promise; triggerSubsyncFromConfig: () => Promise; markLastCardAsAudioCard: () => Promise; + dispatchSessionAction: CliCommandRuntimeServiceDepsParams['dispatchSessionAction']; getAnilistStatus: CliCommandRuntimeServiceDepsParams['anilist']['getStatus']; clearAnilistToken: CliCommandRuntimeServiceDepsParams['anilist']['clearToken']; openAnilistSetup: CliCommandRuntimeServiceDepsParams['anilist']['openSetup']; @@ -113,6 +114,7 @@ function createCliCommandDepsFromContext( hasMainWindow: context.hasMainWindow, runYoutubePlaybackFlow: context.runYoutubePlaybackFlow, }, + dispatchSessionAction: context.dispatchSessionAction, ui: { openFirstRunSetup: context.openFirstRunSetup, openYomitanSettings: context.openYomitanSettings, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index eb0f348c..0e9646f1 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -73,7 +73,9 @@ export interface MainIpcRuntimeServiceDepsParams { getMecabTokenizer: IpcDepsRuntimeOptions['getMecabTokenizer']; handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand']; getKeybindings: IpcDepsRuntimeOptions['getKeybindings']; + getSessionBindings: IpcDepsRuntimeOptions['getSessionBindings']; getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts']; + dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction']; getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey']; getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey']; getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig']; @@ -178,6 +180,7 @@ export interface CliCommandRuntimeServiceDepsParams { hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow']; runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow']; }; + dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction']; ui: { openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup']; openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; @@ -233,7 +236,9 @@ export function createMainIpcRuntimeServiceDeps( getMecabTokenizer: params.getMecabTokenizer, handleMpvCommand: params.handleMpvCommand, getKeybindings: params.getKeybindings, + getSessionBindings: params.getSessionBindings, getConfiguredShortcuts: params.getConfiguredShortcuts, + dispatchSessionAction: params.dispatchSessionAction, getStatsToggleKey: params.getStatsToggleKey, getMarkWatchedKey: params.getMarkWatchedKey, getControllerConfig: params.getControllerConfig, @@ -347,6 +352,7 @@ export function createCliCommandRuntimeServiceDeps( hasMainWindow: params.app.hasMainWindow, runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow, }, + dispatchSessionAction: params.dispatchSessionAction, ui: { openFirstRunSetup: params.ui.openFirstRunSetup, openYomitanSettings: params.ui.openYomitanSettings, diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index 82c8a64e..a3061009 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -7,13 +7,16 @@ type MockWindow = { visible: boolean; focused: boolean; ignoreMouseEvents: boolean; + forwardedIgnoreMouseEvents: boolean; webContentsFocused: boolean; showCount: number; hideCount: number; sent: unknown[][]; loading: boolean; url: string; + contentReady: boolean; loadCallbacks: Array<() => void>; + readyToShowCallbacks: Array<() => void>; }; function createMockWindow(): MockWindow & { @@ -28,7 +31,11 @@ function createMockWindow(): MockWindow & { getHideCount: () => number; show: () => void; hide: () => void; + destroy: () => void; focus: () => void; + emitDidFinishLoad: () => void; + emitReadyToShow: () => void; + once: (event: 'ready-to-show', cb: () => void) => void; webContents: { focused: boolean; isLoading: () => boolean; @@ -44,13 +51,16 @@ function createMockWindow(): MockWindow & { visible: false, focused: false, ignoreMouseEvents: false, + forwardedIgnoreMouseEvents: false, webContentsFocused: false, showCount: 0, hideCount: 0, sent: [], loading: false, url: 'file:///overlay/index.html?layer=modal', + contentReady: true, loadCallbacks: [], + readyToShowCallbacks: [], }; const window = { ...state, @@ -58,8 +68,9 @@ function createMockWindow(): MockWindow & { isVisible: () => state.visible, isFocused: () => state.focused, getURL: () => state.url, - setIgnoreMouseEvents: (ignore: boolean, _options?: { forward?: boolean }) => { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { state.ignoreMouseEvents = ignore; + state.forwardedIgnoreMouseEvents = options?.forward === true; }, setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {}, moveTop: () => {}, @@ -73,9 +84,28 @@ function createMockWindow(): MockWindow & { state.visible = false; state.hideCount += 1; }, + destroy: () => { + state.destroyed = true; + state.visible = false; + }, focus: () => { state.focused = true; }, + emitDidFinishLoad: () => { + const callbacks = state.loadCallbacks.splice(0); + for (const callback of callbacks) { + callback(); + } + }, + emitReadyToShow: () => { + const callbacks = state.readyToShowCallbacks.splice(0); + for (const callback of callbacks) { + callback(); + } + }, + once: (_event: 'ready-to-show', cb: () => void) => { + state.readyToShowCallbacks.push(cb); + }, webContents: { isLoading: () => state.loading, getURL: () => state.url, @@ -139,6 +169,25 @@ function createMockWindow(): MockWindow & { }, }); + Object.defineProperty(window, 'forwardedIgnoreMouseEvents', { + get: () => state.forwardedIgnoreMouseEvents, + set: (value: boolean) => { + state.forwardedIgnoreMouseEvents = value; + }, + }); + + Object.defineProperty(window, 'contentReady', { + get: () => state.contentReady, + set: (value: boolean) => { + state.contentReady = value; + (window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady = + value; + }, + }); + + (window as typeof window & { __subminerOverlayContentReady?: boolean }).__subminerOverlayContentReady = + state.contentReady; + return window; } @@ -195,10 +244,29 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () => assert.deepEqual(window.sent, [['jimaku:open']]); }); +test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => { + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => null, + createModalWindow: () => null, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + assert.equal( + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }), + false, + ); + assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().has('runtime-options'), false); +}); + test('sendToActiveOverlayWindow waits for blank modal URL before sending open command', () => { const window = createMockWindow(); window.url = ''; window.loading = true; + window.contentReady = false; const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, getModalWindow: () => window as never, @@ -215,11 +283,13 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co assert.equal(sent, true); assert.deepEqual(window.sent, []); - - assert.equal(window.loadCallbacks.length, 1); window.loading = false; window.url = 'file:///overlay/index.html?layer=modal'; - window.loadCallbacks[0]!(); + window.emitDidFinishLoad(); + assert.deepEqual(window.sent, []); + + window.contentReady = true; + window.emitReadyToShow(); runtime.notifyOverlayModalOpened('runtime-options'); assert.deepEqual(window.sent, [['runtime-options:open']]); @@ -248,10 +318,10 @@ test('handleOverlayModalClosed hides modal window only after all pending modals ); runtime.handleOverlayModalClosed('runtime-options'); - assert.equal(window.getHideCount(), 0); + assert.equal(window.isDestroyed(), false); runtime.handleOverlayModalClosed('subsync'); - assert.equal(window.getHideCount(), 1); + assert.equal(window.isDestroyed(), true); }); test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => { @@ -325,11 +395,12 @@ test('modal window path makes visible main overlay click-through until modal clo assert.equal(sent, true); assert.equal(mainWindow.ignoreMouseEvents, true); + assert.equal(mainWindow.forwardedIgnoreMouseEvents, true); assert.equal(modalWindow.ignoreMouseEvents, false); runtime.handleOverlayModalClosed('youtube-track-picker'); - assert.equal(mainWindow.ignoreMouseEvents, false); + assert.equal(mainWindow.ignoreMouseEvents, true); }); test('modal window path hides visible main overlay until modal closes', () => { @@ -359,8 +430,8 @@ test('modal window path hides visible main overlay until modal closes', () => { runtime.handleOverlayModalClosed('youtube-track-picker'); - assert.equal(mainWindow.getShowCount(), 1); - assert.equal(mainWindow.isVisible(), true); + assert.equal(mainWindow.getShowCount(), 0); + assert.equal(mainWindow.isVisible(), false); }); test('modal runtime notifies callers when modal input state becomes active/inactive', () => { @@ -437,7 +508,7 @@ test('notifyOverlayModalOpened enables input on visible main overlay window when assert.equal(mainWindow.webContentsFocused, true); }); -test('handleOverlayModalClosed resets modal state even when modal window does not exist', () => { +test('handleOverlayModalClosed is a no-op when no modal window can be targeted', () => { const state: boolean[] = []; const runtime = createOverlayModalRuntimeService( { @@ -454,16 +525,17 @@ test('handleOverlayModalClosed resets modal state even when modal window does no }, ); - runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { restoreOnModalClose: 'runtime-options', }); + assert.equal(sent, false); runtime.notifyOverlayModalOpened('runtime-options'); runtime.handleOverlayModalClosed('runtime-options'); - assert.deepEqual(state, [true, false]); + assert.deepEqual(state, []); }); -test('handleOverlayModalClosed hides modal window for single kiku modal', () => { +test('handleOverlayModalClosed destroys modal window for single kiku modal', () => { const window = createMockWindow(); const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, @@ -482,12 +554,56 @@ test('handleOverlayModalClosed hides modal window for single kiku modal', () => ); runtime.handleOverlayModalClosed('kiku'); - assert.equal(window.getHideCount(), 1); + assert.equal(window.isDestroyed(), true); assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0); }); -test('modal fallback reveal keeps mouse events ignored until modal confirms open', async () => { +test('modal fallback reveal skips showing window when content is not ready', async () => { const window = createMockWindow(); + let scheduledReveal: (() => void) | null = null; + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => { + throw new Error('modal window should not be created when already present'); + }, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }, { + scheduleRevealFallback: (callback) => { + scheduledReveal = callback; + return { scheduled: true } as never; + }, + clearRevealFallback: () => { + scheduledReveal = null; + }, + }); + + window.loading = true; + window.url = ''; + window.contentReady = false; + + const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { + restoreOnModalClose: 'jimaku', + }); + + assert.equal(sent, true); + if (scheduledReveal === null) { + throw new Error('expected reveal callback'); + } + const runScheduledReveal: () => void = scheduledReveal; + runScheduledReveal(); + + assert.equal(window.getShowCount(), 0); + + runtime.notifyOverlayModalOpened('jimaku'); + assert.equal(window.getShowCount(), 1); + assert.equal(window.ignoreMouseEvents, false); +}); + +test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering open event', () => { + const window = createMockWindow(); + window.contentReady = false; const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, getModalWindow: () => window as never, @@ -498,32 +614,162 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open setModalWindowBounds: () => {}, }); - window.loading = true; - window.url = ''; + const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); - const sent = runtime.sendToActiveOverlayWindow('jimaku:open', undefined, { - restoreOnModalClose: 'jimaku', + assert.equal(sent, true); + assert.deepEqual(window.sent, []); + window.emitDidFinishLoad(); + assert.deepEqual(window.sent, []); + + window.contentReady = true; + window.emitReadyToShow(); + assert.deepEqual(window.sent, [['runtime-options:open']]); +}); + +test('sendToActiveOverlayWindow flushes every queued load and ready listener before sending', () => { + const window = createMockWindow(); + window.contentReady = false; + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => { + throw new Error('modal window should not be created when already present'); + }, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + assert.equal( + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }), + true, + ); + assert.equal( + runtime.sendToActiveOverlayWindow('session-help:open', undefined, { + restoreOnModalClose: 'session-help', + }), + true, + ); + assert.deepEqual(window.sent, []); + + window.emitDidFinishLoad(); + assert.deepEqual(window.sent, []); + + window.contentReady = true; + window.emitReadyToShow(); + assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]); +}); + +test('modal reopen creates a fresh window after close destroys the previous one', () => { + const firstWindow = createMockWindow(); + const secondWindow = createMockWindow(); + let currentModal: ReturnType | null = firstWindow; + + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => currentModal as never, + createModalWindow: () => { + currentModal = secondWindow; + return secondWindow as never; + }, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + runtime.notifyOverlayModalOpened('runtime-options'); + runtime.handleOverlayModalClosed('runtime-options'); + + assert.equal(firstWindow.isDestroyed(), true); + + const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + + assert.equal(sent, true); + assert.equal(currentModal, secondWindow); + assert.equal(secondWindow.getShowCount(), 0); +}); + +test('modal reopen after close-destroy notifies state change on fresh window lifecycle', () => { + const firstWindow = createMockWindow(); + const secondWindow = createMockWindow(); + let currentModal: ReturnType | null = firstWindow; + const state: boolean[] = []; + + const runtime = createOverlayModalRuntimeService( + { + getMainWindow: () => null, + getModalWindow: () => currentModal as never, + createModalWindow: () => { + currentModal = secondWindow; + return secondWindow as never; + }, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }, + { + onModalStateChange: (active: boolean): void => { + state.push(active); + }, + }, + ); + + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + runtime.notifyOverlayModalOpened('runtime-options'); + runtime.handleOverlayModalClosed('runtime-options'); + + assert.deepEqual(state, [true, false]); + assert.equal(firstWindow.isDestroyed(), true); + + runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', + }); + runtime.notifyOverlayModalOpened('runtime-options'); + + assert.deepEqual(state, [true, false, true]); + assert.equal(currentModal, secondWindow); +}); + +test('visible stale modal window is made interactive again before reopening', () => { + const window = createMockWindow(); + window.visible = true; + window.focused = true; + window.webContentsFocused = false; + window.ignoreMouseEvents = true; + + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => window as never, + createModalWindow: () => window as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, { + restoreOnModalClose: 'runtime-options', }); assert.equal(sent, true); assert.equal(window.ignoreMouseEvents, false); - - await new Promise((resolve) => { - setTimeout(resolve, 260); - }); - - assert.equal(window.getShowCount(), 1); - assert.equal(window.ignoreMouseEvents, false); - - runtime.notifyOverlayModalOpened('jimaku'); - assert.equal(window.ignoreMouseEvents, false); + assert.equal(window.isFocused(), true); + assert.equal(window.webContentsFocused, true); + assert.deepEqual(window.sent, [['runtime-options:open']]); }); test('waitForModalOpen resolves true after modal acknowledgement', async () => { + const modalWindow = createMockWindow(); const runtime = createOverlayModalRuntimeService({ getMainWindow: () => null, - getModalWindow: () => null, - createModalWindow: () => null, + getModalWindow: () => modalWindow as never, + createModalWindow: () => modalWindow as never, getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), setModalWindowBounds: () => {}, }); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 0f5e0792..242e54ea 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -1,9 +1,30 @@ import type { BrowserWindow } from 'electron'; import type { OverlayHostedModal } from '../shared/ipc/contracts'; import type { WindowGeometry } from '../types'; +import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags'; const MODAL_REVEAL_FALLBACK_DELAY_MS = 250; +function requestOverlayApplicationFocus(): void { + try { + const electron = require('electron') as { + app?: { + focus?: (options?: { steal?: boolean }) => void; + }; + }; + electron.app?.focus?.({ steal: true }); + } catch { + // Ignore focus-steal failures in non-Electron test environments. + } +} + +function setWindowFocusable(window: BrowserWindow): void { + const maybeFocusableWindow = window as BrowserWindow & { + setFocusable?: (focusable: boolean) => void; + }; + maybeFocusableWindow.setFocusable?.(true); +} + export interface OverlayWindowResolver { getMainWindow: () => BrowserWindow | null; getModalWindow: () => BrowserWindow | null; @@ -29,8 +50,15 @@ export interface OverlayModalRuntime { getRestoreVisibleOverlayOnModalClose: () => Set; } +type RevealFallbackHandle = NonNullable[0]>; + export interface OverlayModalRuntimeOptions { onModalStateChange?: (isActive: boolean) => void; + scheduleRevealFallback?: ( + callback: () => void, + delayMs: number, + ) => RevealFallbackHandle; + clearRevealFallback?: (timeout: RevealFallbackHandle) => void; } export function createOverlayModalRuntimeService( @@ -42,8 +70,16 @@ export function createOverlayModalRuntimeService( let modalActive = false; let mainWindowMousePassthroughForcedByModal = false; let mainWindowHiddenByModal = false; + let modalWindowPrimedForImmediateShow = false; let pendingModalWindowReveal: BrowserWindow | null = null; - let pendingModalWindowRevealTimeout: ReturnType | null = null; + let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null; + const scheduleRevealFallback = ( + callback: () => void, + delayMs: number, + ): RevealFallbackHandle => + (options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs); + const clearRevealFallback = (timeout: RevealFallbackHandle): void => + (options.clearRevealFallback ?? globalThis.clearTimeout)(timeout); const notifyModalStateChange = (nextState: boolean): void => { if (modalActive === nextState) return; @@ -87,9 +123,21 @@ export function createOverlayModalRuntimeService( }; const isWindowReadyForIpc = (window: BrowserWindow): boolean => { + if (window.isDestroyed()) { + return false; + } if (window.webContents.isLoading()) { return false; } + const overlayWindow = window as BrowserWindow & { + [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean; + }; + if ( + typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' && + overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true + ) { + return false; + } const currentURL = window.webContents.getURL(); return currentURL !== '' && currentURL !== 'about:blank'; }; @@ -109,11 +157,17 @@ export function createOverlayModalRuntimeService( return; } - window.webContents.once('did-finish-load', () => { - if (!window.isDestroyed() && !window.webContents.isLoading()) { - sendNow(window); + let delivered = false; + const deliverWhenReady = (): void => { + if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) { + return; } - }); + delivered = true; + sendNow(window); + }; + + window.webContents.once('did-finish-load', deliverWhenReady); + window.once('ready-to-show', deliverWhenReady); }; const showModalWindow = ( @@ -122,6 +176,8 @@ export function createOverlayModalRuntimeService( passThroughMouseEvents: boolean; } = { passThroughMouseEvents: false }, ): void => { + setWindowFocusable(window); + requestOverlayApplicationFocus(); if (!window.isVisible()) { window.show(); } @@ -138,15 +194,14 @@ export function createOverlayModalRuntimeService( }; const ensureModalWindowInteractive = (window: BrowserWindow): void => { + setWindowFocusable(window); + requestOverlayApplicationFocus(); + window.setIgnoreMouseEvents(false); + elevateModalWindow(window); + if (window.isVisible()) { - window.setIgnoreMouseEvents(false); - if (!window.isFocused()) { - window.focus(); - } - if (!window.webContents.isFocused()) { - window.webContents.focus(); - } - elevateModalWindow(window); + window.focus(); + window.webContents.focus(); return; } @@ -166,7 +221,7 @@ export function createOverlayModalRuntimeService( return; } - clearTimeout(pendingModalWindowRevealTimeout); + clearRevealFallback(pendingModalWindowRevealTimeout); pendingModalWindowRevealTimeout = null; pendingModalWindowReveal = null; }; @@ -225,12 +280,15 @@ export function createOverlayModalRuntimeService( return; } - pendingModalWindowRevealTimeout = setTimeout(() => { + pendingModalWindowRevealTimeout = scheduleRevealFallback(() => { const targetWindow = pendingModalWindowReveal; clearPendingModalWindowReveal(); if (!targetWindow || targetWindow.isDestroyed() || targetWindow.isVisible()) { return; } + if (!isWindowReadyForIpc(targetWindow)) { + return; + } showModalWindow(targetWindow, { passThroughMouseEvents: false }); }, MODAL_REVEAL_FALLBACK_DELAY_MS); }; @@ -256,9 +314,9 @@ export function createOverlayModalRuntimeService( }; if (restoreOnModalClose) { - restoreVisibleOverlayOnModalClose.add(restoreOnModalClose); const mainWindow = getTargetOverlayWindow(); if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) { + restoreVisibleOverlayOnModalClose.add(restoreOnModalClose); sendOrQueueForWindow(mainWindow, (window) => { if (payload === undefined) { window.webContents.send(channel); @@ -272,15 +330,23 @@ export function createOverlayModalRuntimeService( const modalWindow = resolveModalWindow(); if (!modalWindow) return false; + restoreVisibleOverlayOnModalClose.add(restoreOnModalClose); deps.setModalWindowBounds(deps.getModalGeometry()); const wasVisible = modalWindow.isVisible(); if (!wasVisible) { - scheduleModalWindowReveal(modalWindow); + if (modalWindowPrimedForImmediateShow && isWindowReadyForIpc(modalWindow)) { + showModalWindow(modalWindow); + } else { + scheduleModalWindowReveal(modalWindow); + } } else if (!modalWindow.isFocused()) { showModalWindow(modalWindow); } sendOrQueueForWindow(modalWindow, (window) => { + if (window.isVisible()) { + ensureModalWindowInteractive(window); + } if (payload === undefined) { window.webContents.send(channel); } else { @@ -320,12 +386,13 @@ export function createOverlayModalRuntimeService( const modalWindow = deps.getModalWindow(); if (restoreVisibleOverlayOnModalClose.size === 0) { clearPendingModalWindowReveal(); - notifyModalStateChange(false); - setMainWindowMousePassthroughForModal(false); - setMainWindowVisibilityForModal(false); if (modalWindow && !modalWindow.isDestroyed()) { - modalWindow.hide(); + modalWindow.destroy(); } + modalWindowPrimedForImmediateShow = false; + mainWindowMousePassthroughForcedByModal = false; + mainWindowHiddenByModal = false; + notifyModalStateChange(false); } }; @@ -350,14 +417,7 @@ export function createOverlayModalRuntimeService( } if (targetWindow.isVisible()) { - targetWindow.setIgnoreMouseEvents(false); - elevateModalWindow(targetWindow); - if (!targetWindow.isFocused()) { - targetWindow.focus(); - } - if (!targetWindow.webContents.isFocused()) { - targetWindow.webContents.focus(); - } + ensureModalWindowInteractive(targetWindow); return; } diff --git a/src/main/overlay-visibility-runtime.ts b/src/main/overlay-visibility-runtime.ts index 45b59d8e..fff9b868 100644 --- a/src/main/overlay-visibility-runtime.ts +++ b/src/main/overlay-visibility-runtime.ts @@ -12,10 +12,14 @@ export interface OverlayVisibilityRuntimeDeps { getVisibleOverlayVisible: () => boolean; getForceMousePassthrough: () => boolean; getWindowTracker: () => BaseWindowTracker | null; + getLastKnownWindowsForegroundProcessName?: () => string | null; + getWindowsOverlayProcessName?: () => string | null; + getWindowsFocusHandoffGraceActive?: () => boolean; getTrackerNotReadyWarningShown: () => boolean; setTrackerNotReadyWarningShown: (shown: boolean) => void; updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; ensureOverlayWindowLevel: (window: BrowserWindow) => void; + syncWindowsOverlayToMpvZOrder?: (window: BrowserWindow) => void; syncPrimaryOverlayWindowLayer: (layer: 'visible') => void; enforceOverlayLayerOrder: () => void; syncOverlayShortcuts: () => void; @@ -36,12 +40,20 @@ export function createOverlayVisibilityRuntimeService( return { updateVisibleOverlayVisibility(): void { + const visibleOverlayVisible = deps.getVisibleOverlayVisible(); + const forceMousePassthrough = deps.getForceMousePassthrough(); + const windowTracker = deps.getWindowTracker(); + const mainWindow = deps.getMainWindow(); + updateVisibleOverlayVisibility({ - visibleOverlayVisible: deps.getVisibleOverlayVisible(), + visibleOverlayVisible, modalActive: deps.getModalActive(), - forceMousePassthrough: deps.getForceMousePassthrough(), - mainWindow: deps.getMainWindow(), - windowTracker: deps.getWindowTracker(), + forceMousePassthrough, + mainWindow, + windowTracker, + lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(), + windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null, + windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false, trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(), setTrackerNotReadyWarningShown: (shown: boolean) => { deps.setTrackerNotReadyWarningShown(shown); @@ -49,6 +61,8 @@ export function createOverlayVisibilityRuntimeService( updateVisibleOverlayBounds: (geometry: WindowGeometry) => deps.updateVisibleOverlayBounds(geometry), ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window), + syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) => + deps.syncWindowsOverlayToMpvZOrder?.(window), syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer), enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(), diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index 4c08ead1..5fa4fd0e 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -16,6 +16,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => { unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), stopSubtitleWebsocket: () => calls.push('stop-ws'), stopTexthookerService: () => calls.push('stop-texthooker'), + clearWindowsVisibleOverlayForegroundPollLoop: () => calls.push('clear-windows-visible-overlay-poll'), destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'), destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'), destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'), @@ -40,9 +41,10 @@ test('on will quit cleanup handler runs all cleanup steps', () => { }); cleanup(); - assert.equal(calls.length, 28); + assert.equal(calls.length, 29); assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[calls.length - 1], 'stop-discord-presence'); + assert.ok(calls.includes('clear-windows-visible-overlay-poll')); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); }); diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index 4dd08be7..5e90c0e7 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -6,6 +6,7 @@ export function createOnWillQuitCleanupHandler(deps: { unregisterAllGlobalShortcuts: () => void; stopSubtitleWebsocket: () => void; stopTexthookerService: () => void; + clearWindowsVisibleOverlayForegroundPollLoop: () => void; destroyMainOverlayWindow: () => void; destroyModalOverlayWindow: () => void; destroyYomitanParserWindow: () => void; @@ -36,6 +37,7 @@ export function createOnWillQuitCleanupHandler(deps: { deps.unregisterAllGlobalShortcuts(); deps.stopSubtitleWebsocket(); deps.stopTexthookerService(); + deps.clearWindowsVisibleOverlayForegroundPollLoop(); deps.destroyMainOverlayWindow(); deps.destroyModalOverlayWindow(); deps.destroyYomitanParserWindow(); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index d6814385..e2bd0b3e 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -18,6 +18,8 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), stopSubtitleWebsocket: () => calls.push('stop-ws'), stopTexthookerService: () => calls.push('stop-texthooker'), + clearWindowsVisibleOverlayForegroundPollLoop: () => + calls.push('clear-windows-visible-overlay-foreground-poll-loop'), getMainOverlayWindow: () => ({ isDestroyed: () => false, destroy: () => calls.push('destroy-main-overlay-window'), @@ -85,6 +87,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' assert.ok(calls.includes('destroy-yomitan-settings-window')); assert.ok(calls.includes('stop-jellyfin-remote')); assert.ok(calls.includes('stop-discord-presence')); + assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop')); assert.equal(reconnectTimer, null); assert.equal(immersionTracker, null); }); @@ -99,6 +102,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => { unregisterAllGlobalShortcuts: () => {}, stopSubtitleWebsocket: () => {}, stopTexthookerService: () => {}, + clearWindowsVisibleOverlayForegroundPollLoop: () => {}, getMainOverlayWindow: () => ({ isDestroyed: () => true, destroy: () => calls.push('destroy-main-overlay-window'), diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index 803a0a18..6d4bbb9a 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -25,6 +25,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { unregisterAllGlobalShortcuts: () => void; stopSubtitleWebsocket: () => void; stopTexthookerService: () => void; + clearWindowsVisibleOverlayForegroundPollLoop: () => void; getMainOverlayWindow: () => DestroyableWindow | null; clearMainOverlayWindow: () => void; getModalOverlayWindow: () => DestroyableWindow | null; @@ -64,6 +65,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(), stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(), stopTexthookerService: () => deps.stopTexthookerService(), + clearWindowsVisibleOverlayForegroundPollLoop: () => + deps.clearWindowsVisibleOverlayForegroundPollLoop(), destroyMainOverlayWindow: () => { const window = deps.getMainOverlayWindow(); if (!window) return; diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index 057d3d50..d6bfe9c7 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -42,6 +42,7 @@ test('build cli command context deps maps handlers and values', () => { markLastCardAsAudioCard: async () => { calls.push('mark'); }, + dispatchSessionAction: async () => {}, getAnilistStatus: () => ({}) as never, clearAnilistToken: () => calls.push('clear-token'), openAnilistSetup: () => calls.push('anilist'), diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index fcea1e99..0a161295 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -28,6 +28,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { triggerFieldGrouping: () => Promise; triggerSubsyncFromConfig: () => Promise; markLastCardAsAudioCard: () => Promise; + dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction']; getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; clearAnilistToken: CliCommandContextFactoryDeps['clearAnilistToken']; openAnilistSetup: CliCommandContextFactoryDeps['openAnilistSetup']; @@ -77,6 +78,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { triggerFieldGrouping: deps.triggerFieldGrouping, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, markLastCardAsAudioCard: deps.markLastCardAsAudioCard, + dispatchSessionAction: deps.dispatchSessionAction, getAnilistStatus: deps.getAnilistStatus, clearAnilistToken: deps.clearAnilistToken, openAnilistSetup: deps.openAnilistSetup, diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index bf2565ca..cb826a4c 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -37,6 +37,7 @@ test('cli command context factory composes main deps and context handlers', () = triggerFieldGrouping: async () => {}, triggerSubsyncFromConfig: async () => {}, markLastCardAsAudioCard: async () => {}, + dispatchSessionAction: async () => {}, getAnilistStatus: () => ({ tokenStatus: 'resolved', tokenSource: 'literal', diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index 51de7053..6644283f 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -53,6 +53,7 @@ test('cli command context main deps builder maps state and callbacks', async () markLastCardAsAudioCard: async () => { calls.push('mark-audio'); }, + dispatchSessionAction: async () => {}, getAnilistStatus: () => ({ tokenStatus: 'resolved', diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 4c19c324..18fb7106 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -39,6 +39,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { triggerFieldGrouping: () => Promise; triggerSubsyncFromConfig: () => Promise; markLastCardAsAudioCard: () => Promise; + dispatchSessionAction: CliCommandContextFactoryDeps['dispatchSessionAction']; getAnilistStatus: CliCommandContextFactoryDeps['getAnilistStatus']; clearAnilistToken: () => void; @@ -103,6 +104,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { triggerFieldGrouping: () => deps.triggerFieldGrouping(), triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), markLastCardAsAudioCard: () => deps.markLastCardAsAudioCard(), + dispatchSessionAction: (request) => deps.dispatchSessionAction(request), getAnilistStatus: () => deps.getAnilistStatus(), clearAnilistToken: () => deps.clearAnilistToken(), openAnilistSetup: () => deps.openAnilistSetupWindow(), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index 90053a0a..209a8b79 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -36,6 +36,7 @@ function createDeps() { triggerFieldGrouping: async () => {}, triggerSubsyncFromConfig: async () => {}, markLastCardAsAudioCard: async () => {}, + dispatchSessionAction: async () => {}, getAnilistStatus: () => ({}) as never, clearAnilistToken: () => {}, openAnilistSetup: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index 3b652c0b..f4fd2f31 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -33,6 +33,7 @@ export type CliCommandContextFactoryDeps = { triggerFieldGrouping: () => Promise; triggerSubsyncFromConfig: () => Promise; markLastCardAsAudioCard: () => Promise; + dispatchSessionAction: CliCommandRuntimeServiceContext['dispatchSessionAction']; getAnilistStatus: CliCommandRuntimeServiceContext['getAnilistStatus']; clearAnilistToken: CliCommandRuntimeServiceContext['clearAnilistToken']; openAnilistSetup: CliCommandRuntimeServiceContext['openAnilistSetup']; @@ -89,6 +90,7 @@ export function createCliCommandContext( triggerFieldGrouping: deps.triggerFieldGrouping, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, markLastCardAsAudioCard: deps.markLastCardAsAudioCard, + dispatchSessionAction: deps.dispatchSessionAction, getAnilistStatus: deps.getAnilistStatus, clearAnilistToken: deps.clearAnilistToken, openAnilistSetup: deps.openAnilistSetup, diff --git a/src/main/runtime/composers/cli-startup-composer.test.ts b/src/main/runtime/composers/cli-startup-composer.test.ts index 04fa7e49..50c1cab4 100644 --- a/src/main/runtime/composers/cli-startup-composer.test.ts +++ b/src/main/runtime/composers/cli-startup-composer.test.ts @@ -30,6 +30,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => { triggerFieldGrouping: async () => {}, triggerSubsyncFromConfig: async () => {}, markLastCardAsAudioCard: async () => {}, + dispatchSessionAction: async () => {}, getAnilistStatus: () => ({}) as never, clearAnilistToken: () => {}, openAnilistSetupWindow: () => {}, diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index d9f768af..8cb26a7c 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -53,7 +53,9 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b saveSubtitlePosition: () => {}, getMecabTokenizer: () => null, getKeybindings: () => [], + getSessionBindings: () => [], getConfiguredShortcuts: () => ({}) as never, + dispatchSessionAction: async () => {}, getStatsToggleKey: () => 'Backquote', getMarkWatchedKey: () => 'KeyW', getControllerConfig: () => ({}) as never, diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index 0c2cf22c..f3ddad99 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -21,6 +21,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler unregisterAllGlobalShortcuts: () => {}, stopSubtitleWebsocket: () => {}, stopTexthookerService: () => {}, + clearWindowsVisibleOverlayForegroundPollLoop: () => {}, getMainOverlayWindow: () => null, clearMainOverlayWindow: () => {}, getModalOverlayWindow: () => null, diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index 0a5e228e..9ad4e1c6 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -11,9 +11,14 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { const config = deepCloneConfig(DEFAULT_CONFIG); const calls: string[] = []; const ankiPatches: Array<{ enabled: boolean }> = []; + const sessionBindingWarnings: string[][] = []; const applyHotReload = createConfigHotReloadAppliedHandler({ setKeybindings: () => calls.push('set:keybindings'), + setSessionBindings: (_sessionBindings, warnings) => { + calls.push('set:session-bindings'); + sessionBindingWarnings.push(warnings.map((warning) => warning.message)); + }, refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'), setSecondarySubMode: (mode) => calls.push(`set:secondary:${mode}`), broadcastToOverlayWindows: (channel, payload) => @@ -37,11 +42,18 @@ test('createConfigHotReloadAppliedHandler runs all hot-reload effects', () => { ); assert.ok(calls.includes('set:keybindings')); + assert.ok(calls.includes('set:session-bindings')); assert.ok(calls.includes('refresh:shortcuts')); assert.ok(calls.includes(`set:secondary:${config.secondarySub.defaultMode}`)); assert.ok(calls.some((entry) => entry.startsWith('broadcast:secondary-subtitle:mode:'))); assert.ok(calls.includes('broadcast:config:hot-reload:object')); assert.deepEqual(ankiPatches, [{ enabled: config.ankiConnect.ai.enabled }]); + assert.equal(sessionBindingWarnings.length, 1); + assert.ok( + sessionBindingWarnings[0]?.some((message) => + message.includes('Rename shortcuts.toggleVisibleOverlayGlobal'), + ), + ); }); test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => { @@ -50,6 +62,7 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie const applyHotReload = createConfigHotReloadAppliedHandler({ setKeybindings: () => calls.push('set:keybindings'), + setSessionBindings: () => calls.push('set:session-bindings'), refreshGlobalAndOverlayShortcuts: () => calls.push('refresh:shortcuts'), setSecondarySubMode: () => calls.push('set:secondary'), broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), @@ -64,7 +77,35 @@ test('createConfigHotReloadAppliedHandler skips optional effects when no hot fie config, ); - assert.deepEqual(calls, ['set:keybindings']); + assert.deepEqual(calls, ['set:keybindings', 'set:session-bindings']); +}); + +test('createConfigHotReloadAppliedHandler forwards compiled session-binding warnings', () => { + const config = deepCloneConfig(DEFAULT_CONFIG); + config.shortcuts.openSessionHelp = 'Ctrl+?'; + const warnings: string[][] = []; + + const applyHotReload = createConfigHotReloadAppliedHandler({ + setKeybindings: () => {}, + setSessionBindings: (_sessionBindings, sessionBindingWarnings) => { + warnings.push(sessionBindingWarnings.map((warning) => warning.message)); + }, + refreshGlobalAndOverlayShortcuts: () => {}, + setSecondarySubMode: () => {}, + broadcastToOverlayWindows: () => {}, + applyAnkiRuntimeConfigPatch: () => {}, + }); + + applyHotReload( + { + hotReloadFields: ['shortcuts'], + restartRequiredFields: [], + }, + config, + ); + + assert.equal(warnings.length, 1); + assert.ok(warnings[0]?.some((message) => message.includes('Unsupported accelerator key token'))); }); test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop notification', () => { diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index cf4f9c64..0f752943 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -1,10 +1,16 @@ import type { ConfigHotReloadDiff } from '../../core/services/config-hot-reload'; +import { compileSessionBindings } from '../../core/services/session-bindings'; import { resolveKeybindings } from '../../core/utils/keybindings'; -import { DEFAULT_KEYBINDINGS } from '../../config'; +import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config'; +import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config'; import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types'; type ConfigHotReloadAppliedDeps = { setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; + setSessionBindings: ( + sessionBindings: ConfigHotReloadPayload['sessionBindings'], + sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'], + ) => void; refreshGlobalAndOverlayShortcuts: () => void; setSecondarySubMode: (mode: SecondarySubMode) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; @@ -33,8 +39,23 @@ export function resolveSubtitleStyleForRenderer(config: ResolvedConfig) { } export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotReloadPayload { + const keybindings = resolveKeybindings(config, DEFAULT_KEYBINDINGS); + const { bindings: sessionBindings, warnings: sessionBindingWarnings } = compileSessionBindings({ + keybindings, + shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG), + statsToggleKey: config.stats.toggleKey, + platform: + process.platform === 'darwin' + ? 'darwin' + : process.platform === 'win32' + ? 'win32' + : 'linux', + rawConfig: config, + }); return { - keybindings: resolveKeybindings(config, DEFAULT_KEYBINDINGS), + keybindings, + sessionBindings, + sessionBindingWarnings, subtitleStyle: resolveSubtitleStyleForRenderer(config), subtitleSidebar: config.subtitleSidebar, secondarySubMode: config.secondarySub.defaultMode, @@ -45,6 +66,7 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied return (diff: ConfigHotReloadDiff, config: ResolvedConfig): void => { const payload = buildConfigHotReloadPayload(config); deps.setKeybindings(payload.keybindings); + deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings); if (diff.hotReloadFields.includes('shortcuts')) { deps.refreshGlobalAndOverlayShortcuts(); diff --git a/src/main/runtime/config-hot-reload-main-deps.test.ts b/src/main/runtime/config-hot-reload-main-deps.test.ts index fc01aa05..c5a83271 100644 --- a/src/main/runtime/config-hot-reload-main-deps.test.ts +++ b/src/main/runtime/config-hot-reload-main-deps.test.ts @@ -86,26 +86,35 @@ test('config hot reload message main deps builder maps notifications', () => { test('config hot reload applied main deps builder maps callbacks', () => { const calls: string[] = []; - const deps = createBuildConfigHotReloadAppliedMainDepsHandler({ + const warningCounts: number[] = []; + const buildDeps = createBuildConfigHotReloadAppliedMainDepsHandler({ setKeybindings: () => calls.push('keybindings'), + setSessionBindings: (_sessionBindings, warnings) => { + calls.push('session-bindings'); + warningCounts.push(warnings.length); + }, refreshGlobalAndOverlayShortcuts: () => calls.push('refresh-shortcuts'), setSecondarySubMode: () => calls.push('set-secondary'), broadcastToOverlayWindows: (channel) => calls.push(`broadcast:${channel}`), applyAnkiRuntimeConfigPatch: () => calls.push('apply-anki'), - })(); + }); + const deps = buildDeps(); deps.setKeybindings([]); + deps.setSessionBindings([], []); deps.refreshGlobalAndOverlayShortcuts(); deps.setSecondarySubMode('hover'); deps.broadcastToOverlayWindows('config:hot-reload', {}); deps.applyAnkiRuntimeConfigPatch({ ai: true }); assert.deepEqual(calls, [ 'keybindings', + 'session-bindings', 'refresh-shortcuts', 'set-secondary', 'broadcast:config:hot-reload', 'apply-anki', ]); + assert.deepEqual(warningCounts, [0]); }); test('config hot reload runtime main deps builder maps runtime callbacks', () => { diff --git a/src/main/runtime/config-hot-reload-main-deps.ts b/src/main/runtime/config-hot-reload-main-deps.ts index e93ca694..dfff9da9 100644 --- a/src/main/runtime/config-hot-reload-main-deps.ts +++ b/src/main/runtime/config-hot-reload-main-deps.ts @@ -62,6 +62,10 @@ export function createBuildConfigHotReloadMessageMainDepsHandler( export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void; + setSessionBindings: ( + sessionBindings: ConfigHotReloadPayload['sessionBindings'], + sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'], + ) => void; refreshGlobalAndOverlayShortcuts: () => void; setSecondarySubMode: (mode: SecondarySubMode) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; @@ -72,6 +76,10 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { return () => ({ setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => deps.setKeybindings(keybindings), + setSessionBindings: ( + sessionBindings: ConfigHotReloadPayload['sessionBindings'], + sessionBindingWarnings: ConfigHotReloadPayload['sessionBindingWarnings'], + ) => deps.setSessionBindings(sessionBindings, sessionBindingWarnings), refreshGlobalAndOverlayShortcuts: () => deps.refreshGlobalAndOverlayShortcuts(), setSecondarySubMode: (mode: SecondarySubMode) => deps.setSecondarySubMode(mode), broadcastToOverlayWindows: (channel: string, payload: unknown) => diff --git a/src/main/runtime/controller-debug-open.ts b/src/main/runtime/controller-debug-open.ts new file mode 100644 index 00000000..622d7143 --- /dev/null +++ b/src/main/runtime/controller-debug-open.ts @@ -0,0 +1,48 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +const CONTROLLER_DEBUG_MODAL: OverlayHostedModal = 'controller-debug'; +const CONTROLLER_DEBUG_OPEN_TIMEOUT_MS = 1500; + +export async function openControllerDebugModal(deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: CONTROLLER_DEBUG_MODAL, + timeoutMs: CONTROLLER_DEBUG_OPEN_TIMEOUT_MS, + retryWarning: + 'Controller debug modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: IPC_CHANNELS.event.controllerDebugOpen, + modal: CONTROLLER_DEBUG_MODAL, + preferModalWindow: true, + }, + ), + }, + ); +} diff --git a/src/main/runtime/controller-select-open.ts b/src/main/runtime/controller-select-open.ts new file mode 100644 index 00000000..7af280a0 --- /dev/null +++ b/src/main/runtime/controller-select-open.ts @@ -0,0 +1,48 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +const CONTROLLER_SELECT_MODAL: OverlayHostedModal = 'controller-select'; +const CONTROLLER_SELECT_OPEN_TIMEOUT_MS = 1500; + +export async function openControllerSelectModal(deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: CONTROLLER_SELECT_MODAL, + timeoutMs: CONTROLLER_SELECT_OPEN_TIMEOUT_MS, + retryWarning: + 'Controller select modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: IPC_CHANNELS.event.controllerSelectOpen, + modal: CONTROLLER_SELECT_MODAL, + preferModalWindow: true, + }, + ), + }, + ); +} diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index 5272aa2f..4111024b 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -42,7 +42,21 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerFieldGrouping: false, triggerSubsync: false, markAudioCard: false, + toggleStatsOverlay: false, + toggleSubtitleSidebar: false, openRuntimeOptions: false, + openSessionHelp: false, + openControllerSelect: false, + openControllerDebug: false, + openJimaku: false, + openYoutubePicker: false, + openPlaylistBrowser: false, + replayCurrentSubtitle: false, + playNextSubtitle: false, + shiftSubDelayPrevLine: false, + shiftSubDelayNextLine: false, + cycleRuntimeOptionId: undefined, + cycleRuntimeOptionDirection: undefined, anilistStatus: false, anilistLogout: false, anilistSetup: false, @@ -79,6 +93,41 @@ test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => { assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false); }); +test('shouldAutoOpenFirstRunSetup treats numeric startup counts as explicit commands', () => { + assert.equal( + shouldAutoOpenFirstRunSetup(makeArgs({ start: true, copySubtitleCount: 2 })), + false, + ); + assert.equal( + shouldAutoOpenFirstRunSetup(makeArgs({ background: true, mineSentenceCount: 1 })), + false, + ); +}); + +test('shouldAutoOpenFirstRunSetup treats session and stats startup commands as explicit commands', () => { + assert.equal( + shouldAutoOpenFirstRunSetup(makeArgs({ start: true, toggleSubtitleSidebar: true })), + false, + ); + assert.equal( + shouldAutoOpenFirstRunSetup(makeArgs({ background: true, openSessionHelp: true })), + false, + ); + assert.equal( + shouldAutoOpenFirstRunSetup(makeArgs({ start: true, openControllerSelect: true })), + false, + ); + assert.equal( + shouldAutoOpenFirstRunSetup(makeArgs({ background: true, openControllerDebug: true })), + false, + ); + assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, stats: true })), false); + assert.equal( + shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinSubtitleUrlsOnly: true })), + false, + ); +}); + test('setup service auto-completes legacy installs with config and dictionaries', async () => { await withTempDir(async (root) => { const configDir = path.join(root, 'SubMiner'); diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index 4accf574..737c801b 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -68,26 +68,43 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean { args.hideVisibleOverlay || args.copySubtitle || args.copySubtitleMultiple || + args.copySubtitleCount !== undefined || args.mineSentence || args.mineSentenceMultiple || + args.mineSentenceCount !== undefined || args.updateLastCardFromClipboard || args.refreshKnownWords || args.toggleSecondarySub || args.triggerFieldGrouping || args.triggerSubsync || args.markAudioCard || + args.toggleStatsOverlay || + args.toggleSubtitleSidebar || args.openRuntimeOptions || + args.openSessionHelp || + args.openControllerSelect || + args.openControllerDebug || + args.openJimaku || + args.openYoutubePicker || + args.openPlaylistBrowser || + args.replayCurrentSubtitle || + args.playNextSubtitle || + args.shiftSubDelayPrevLine || + args.shiftSubDelayNextLine || + args.cycleRuntimeOptionId !== undefined || args.anilistStatus || args.anilistLogout || args.anilistSetup || args.anilistRetryQueue || args.dictionary || + args.stats || args.jellyfin || args.jellyfinLogin || args.jellyfinLogout || args.jellyfinLibraries || args.jellyfinItems || args.jellyfinSubtitles || + args.jellyfinSubtitleUrlsOnly || args.jellyfinPlay || args.jellyfinRemoteAnnounce || args.jellyfinPreviewAuth || diff --git a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts index 963d84f8..e192fae7 100644 --- a/src/main/runtime/global-shortcuts-runtime-handlers.test.ts +++ b/src/main/runtime/global-shortcuts-runtime-handlers.test.ts @@ -18,6 +18,10 @@ function createShortcuts(): ConfiguredShortcuts { markAudioCard: null, openRuntimeOptions: null, openJimaku: null, + openSessionHelp: null, + openControllerSelect: null, + openControllerDebug: null, + toggleSubtitleSidebar: null, }; } diff --git a/src/main/runtime/global-shortcuts.test.ts b/src/main/runtime/global-shortcuts.test.ts index a2fe05cf..995c0ce1 100644 --- a/src/main/runtime/global-shortcuts.test.ts +++ b/src/main/runtime/global-shortcuts.test.ts @@ -22,6 +22,10 @@ function createShortcuts(): ConfiguredShortcuts { markAudioCard: null, openRuntimeOptions: null, openJimaku: null, + openSessionHelp: null, + openControllerSelect: null, + openControllerDebug: null, + toggleSubtitleSidebar: null, }; } diff --git a/src/main/runtime/immersion-startup.test.ts b/src/main/runtime/immersion-startup.test.ts index e7b5fd32..2388c829 100644 --- a/src/main/runtime/immersion-startup.test.ts +++ b/src/main/runtime/immersion-startup.test.ts @@ -161,6 +161,44 @@ test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv assert.ok(calls.includes('info:Auto-connecting MPV client for immersion tracking')); }); +test('createImmersionTrackerStartupHandler keeps tracker startup alive when mpv auto-connect throws', () => { + const calls: string[] = []; + const trackerInstance = { kind: 'tracker' }; + let assignedTracker: unknown = null; + const handler = createImmersionTrackerStartupHandler({ + getResolvedConfig: () => makeConfig(), + getConfiguredDbPath: () => '/tmp/subminer.db', + createTrackerService: () => trackerInstance, + setTracker: (nextTracker) => { + assignedTracker = nextTracker; + }, + getMpvClient: () => ({ + connected: false, + connect: () => { + throw new Error('socket not ready'); + }, + }), + seedTrackerFromCurrentMedia: () => calls.push('seedTracker'), + logInfo: (message) => calls.push(`info:${message}`), + logDebug: (message) => calls.push(`debug:${message}`), + logWarn: (message, details) => calls.push(`warn:${message}:${(details as Error).message}`), + }); + + handler(); + + assert.equal(assignedTracker, trackerInstance); + assert.ok(calls.includes('seedTracker')); + assert.ok( + calls.includes( + 'warn:MPV auto-connect failed during immersion tracker startup; continuing.:socket not ready', + ), + ); + assert.equal( + calls.some((entry) => entry.startsWith('warn:Immersion tracker startup failed; disabling tracking.')), + false, + ); +}); + test('createImmersionTrackerStartupHandler disables tracker on failure', () => { const calls: string[] = []; let assignedTracker: unknown = 'initial'; diff --git a/src/main/runtime/immersion-startup.ts b/src/main/runtime/immersion-startup.ts index 719f6413..bf533d2d 100644 --- a/src/main/runtime/immersion-startup.ts +++ b/src/main/runtime/immersion-startup.ts @@ -102,7 +102,11 @@ export function createImmersionTrackerStartupHandler( const mpvClient = deps.getMpvClient(); if ((deps.shouldAutoConnectMpv?.() ?? true) && mpvClient && !mpvClient.connected) { deps.logInfo('Auto-connecting MPV client for immersion tracking'); - mpvClient.connect(); + try { + mpvClient.connect(); + } catch (error) { + deps.logWarn('MPV auto-connect failed during immersion tracker startup; continuing.', error); + } } deps.seedTrackerFromCurrentMedia(); } catch (error) { diff --git a/src/main/runtime/jimaku-open.ts b/src/main/runtime/jimaku-open.ts new file mode 100644 index 00000000..6cd6096c --- /dev/null +++ b/src/main/runtime/jimaku-open.ts @@ -0,0 +1,48 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +const JIMAKU_MODAL: OverlayHostedModal = 'jimaku'; +const JIMAKU_OPEN_TIMEOUT_MS = 1500; + +export async function openJimakuModal(deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: JIMAKU_MODAL, + timeoutMs: JIMAKU_OPEN_TIMEOUT_MS, + retryWarning: + 'Jimaku modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: IPC_CHANNELS.event.jimakuOpen, + modal: JIMAKU_MODAL, + preferModalWindow: true, + }, + ), + }, + ); +} diff --git a/src/main/runtime/overlay-hosted-modal-open.test.ts b/src/main/runtime/overlay-hosted-modal-open.test.ts new file mode 100644 index 00000000..adaa8552 --- /dev/null +++ b/src/main/runtime/overlay-hosted-modal-open.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { openOverlayHostedModal } from './overlay-hosted-modal-open'; + +test('openOverlayHostedModal ensures overlay readiness before sending the open event', () => { + const calls: string[] = []; + + const opened = openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: () => { + calls.push('ensureOverlayStartupPrereqs'); + }, + ensureOverlayWindowsReadyForVisibilityActions: () => { + calls.push('ensureOverlayWindowsReadyForVisibilityActions'); + }, + sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => { + calls.push(`send:${channel}`); + assert.equal(payload, undefined); + assert.deepEqual(runtimeOptions, { + restoreOnModalClose: 'runtime-options', + preferModalWindow: undefined, + }); + return true; + }, + }, + { + channel: 'runtime-options:open', + modal: 'runtime-options', + }, + ); + + assert.equal(opened, true); + assert.deepEqual(calls, [ + 'ensureOverlayStartupPrereqs', + 'ensureOverlayWindowsReadyForVisibilityActions', + 'send:runtime-options:open', + ]); +}); + +test('openOverlayHostedModal forwards payload and modal-window preference', () => { + const payload = { sessionId: 'yt-1' }; + + const opened = openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: () => {}, + ensureOverlayWindowsReadyForVisibilityActions: () => {}, + sendToActiveOverlayWindow: (channel, forwardedPayload, runtimeOptions) => { + assert.equal(channel, 'youtube:picker-open'); + assert.deepEqual(forwardedPayload, payload); + assert.deepEqual(runtimeOptions, { + restoreOnModalClose: 'youtube-track-picker', + preferModalWindow: true, + }); + return false; + }, + }, + { + channel: 'youtube:picker-open', + modal: 'youtube-track-picker', + payload, + preferModalWindow: true, + }, + ); + + assert.equal(opened, false); +}); diff --git a/src/main/runtime/overlay-hosted-modal-open.ts b/src/main/runtime/overlay-hosted-modal-open.ts new file mode 100644 index 00000000..15366ae8 --- /dev/null +++ b/src/main/runtime/overlay-hosted-modal-open.ts @@ -0,0 +1,57 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; + +export function openOverlayHostedModal( + deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + }, + input: { + channel: string; + modal: OverlayHostedModal; + payload?: unknown; + preferModalWindow?: boolean; + }, +): boolean { + deps.ensureOverlayStartupPrereqs(); + deps.ensureOverlayWindowsReadyForVisibilityActions(); + return deps.sendToActiveOverlayWindow(input.channel, input.payload, { + restoreOnModalClose: input.modal, + preferModalWindow: input.preferModalWindow, + }); +} + +export async function retryOverlayModalOpen( + deps: { + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; + }, + input: { + modal: OverlayHostedModal; + timeoutMs: number; + retryWarning: string; + sendOpen: () => boolean; + }, +): Promise { + if (!input.sendOpen()) { + return false; + } + + if (await deps.waitForModalOpen(input.modal, input.timeoutMs)) { + return true; + } + + deps.logWarn(input.retryWarning); + if (!input.sendOpen()) { + return false; + } + + return await deps.waitForModalOpen(input.modal, input.timeoutMs); +} diff --git a/src/main/runtime/overlay-modal-input-state.test.ts b/src/main/runtime/overlay-modal-input-state.test.ts index cda2d194..694f7e1c 100644 --- a/src/main/runtime/overlay-modal-input-state.test.ts +++ b/src/main/runtime/overlay-modal-input-state.test.ts @@ -23,6 +23,9 @@ function createModalWindow() { setIgnoreMouseEvents: (ignore: boolean) => { calls.push(`ignore:${ignore}`); }, + setFocusable: (focusable: boolean) => { + calls.push(`focusable:${focusable}`); + }, setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => { calls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`); }, @@ -58,6 +61,7 @@ test('overlay modal input state activates modal window interactivity and syncs d assert.equal(state.getModalInputExclusive(), true); assert.deepEqual(modalWindow.calls, [ + 'focusable:true', 'ignore:false', 'top:true:screen-saver:1', 'focus', @@ -66,6 +70,25 @@ test('overlay modal input state activates modal window interactivity and syncs d assert.deepEqual(calls, ['shortcuts:true', 'visibility']); }); +test('overlay modal input state restores main window focus on deactivation', () => { + const modalWindow = createModalWindow(); + const calls: string[] = []; + const state = createOverlayModalInputState({ + getModalWindow: () => modalWindow as never, + syncOverlayShortcutsForModal: () => {}, + syncOverlayVisibilityForModal: () => {}, + restoreMainWindowFocus: () => { + calls.push('restore-focus'); + }, + }); + + state.handleModalInputStateChange(true); + assert.deepEqual(calls, []); + + state.handleModalInputStateChange(false); + assert.deepEqual(calls, ['restore-focus']); +}); + test('overlay modal input state is idempotent for unchanged state', () => { const calls: string[] = []; const state = createOverlayModalInputState({ diff --git a/src/main/runtime/overlay-modal-input-state.ts b/src/main/runtime/overlay-modal-input-state.ts index b095ca13..fd49a952 100644 --- a/src/main/runtime/overlay-modal-input-state.ts +++ b/src/main/runtime/overlay-modal-input-state.ts @@ -1,9 +1,30 @@ import type { BrowserWindow } from 'electron'; +function requestOverlayApplicationFocus(): void { + try { + const electron = require('electron') as { + app?: { + focus?: (options?: { steal?: boolean }) => void; + }; + }; + electron.app?.focus?.({ steal: true }); + } catch { + // Ignore focus-steal failures in non-Electron test environments. + } +} + +function setWindowFocusable(window: BrowserWindow): void { + const maybeFocusableWindow = window as BrowserWindow & { + setFocusable?: (focusable: boolean) => void; + }; + maybeFocusableWindow.setFocusable?.(true); +} + export type OverlayModalInputStateDeps = { getModalWindow: () => BrowserWindow | null; syncOverlayShortcutsForModal: (isActive: boolean) => void; syncOverlayVisibilityForModal: () => void; + restoreMainWindowFocus?: () => void; }; export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) { @@ -18,6 +39,8 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) { if (isActive) { const modalWindow = deps.getModalWindow(); if (modalWindow && !modalWindow.isDestroyed()) { + setWindowFocusable(modalWindow); + requestOverlayApplicationFocus(); modalWindow.setIgnoreMouseEvents(false); modalWindow.setAlwaysOnTop(true, 'screen-saver', 1); modalWindow.focus(); @@ -29,6 +52,9 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) { deps.syncOverlayShortcutsForModal(isActive); deps.syncOverlayVisibilityForModal(); + if (!isActive) { + deps.restoreMainWindowFocus?.(); + } }; return { diff --git a/src/main/runtime/overlay-runtime-bootstrap.ts b/src/main/runtime/overlay-runtime-bootstrap.ts index cdc6832e..287fba85 100644 --- a/src/main/runtime/overlay-runtime-bootstrap.ts +++ b/src/main/runtime/overlay-runtime-bootstrap.ts @@ -31,6 +31,8 @@ type InitializeOverlayRuntimeCore = (options: { ) => Promise; getKnownWordCacheStatePath: () => string; shouldStartAnkiIntegration: () => boolean; + bindOverlayOwner?: () => void; + releaseOverlayOwner?: () => void; }) => void; export function createInitializeOverlayRuntimeHandler(deps: { diff --git a/src/main/runtime/overlay-runtime-options-main-deps.test.ts b/src/main/runtime/overlay-runtime-options-main-deps.test.ts index c243e13b..83f7f895 100644 --- a/src/main/runtime/overlay-runtime-options-main-deps.test.ts +++ b/src/main/runtime/overlay-runtime-options-main-deps.test.ts @@ -23,6 +23,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () => overlayVisibilityRuntime: { updateVisibleOverlayVisibility: () => calls.push('update-visible'), }, + refreshCurrentSubtitle: () => calls.push('refresh-subtitle'), overlayShortcutsRuntime: { syncOverlayShortcuts: () => calls.push('sync-shortcuts'), }, @@ -53,6 +54,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () => deps.registerGlobalShortcuts(); deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); deps.updateVisibleOverlayVisibility(); + deps.refreshCurrentSubtitle?.(); deps.syncOverlayShortcuts(); deps.showDesktopNotification('title', {}); @@ -68,6 +70,7 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () => 'register-shortcuts', 'visible-bounds', 'update-visible', + 'refresh-subtitle', 'sync-shortcuts', 'notify', ]); diff --git a/src/main/runtime/overlay-runtime-options-main-deps.ts b/src/main/runtime/overlay-runtime-options-main-deps.ts index 3022e066..122d0044 100644 --- a/src/main/runtime/overlay-runtime-options-main-deps.ts +++ b/src/main/runtime/overlay-runtime-options-main-deps.ts @@ -21,6 +21,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { overlayVisibilityRuntime: { updateVisibleOverlayVisibility: () => void; }; + refreshCurrentSubtitle?: () => void; overlayShortcutsRuntime: { syncOverlayShortcuts: () => void; }; @@ -39,6 +40,8 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; getKnownWordCacheStatePath: () => string; shouldStartAnkiIntegration: () => boolean; + bindOverlayOwner?: () => void; + releaseOverlayOwner?: () => void; }) { return (): OverlayRuntimeOptionsMainDeps => ({ getBackendOverride: () => deps.appState.backendOverride, @@ -53,6 +56,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { isVisibleOverlayVisible: () => deps.overlayManager.getVisibleOverlayVisible(), updateVisibleOverlayVisibility: () => deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility(), + refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(), getOverlayWindows: () => deps.getOverlayWindows(), syncOverlayShortcuts: () => deps.overlayShortcutsRuntime.syncOverlayShortcuts(), setWindowTracker: (tracker) => { @@ -71,5 +75,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: { createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(), + bindOverlayOwner: deps.bindOverlayOwner, + releaseOverlayOwner: deps.releaseOverlayOwner, }); } diff --git a/src/main/runtime/overlay-runtime-options.test.ts b/src/main/runtime/overlay-runtime-options.test.ts index b3f20e87..90a35960 100644 --- a/src/main/runtime/overlay-runtime-options.test.ts +++ b/src/main/runtime/overlay-runtime-options.test.ts @@ -11,6 +11,7 @@ test('build initialize overlay runtime options maps dependencies', () => { updateVisibleOverlayBounds: () => calls.push('update-visible-bounds'), isVisibleOverlayVisible: () => true, updateVisibleOverlayVisibility: () => calls.push('update-visible'), + refreshCurrentSubtitle: () => calls.push('refresh-subtitle'), getOverlayWindows: () => [], syncOverlayShortcuts: () => calls.push('sync-shortcuts'), setWindowTracker: () => calls.push('set-tracker'), @@ -41,6 +42,7 @@ test('build initialize overlay runtime options maps dependencies', () => { options.registerGlobalShortcuts(); options.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); options.updateVisibleOverlayVisibility(); + options.refreshCurrentSubtitle?.(); options.syncOverlayShortcuts(); options.setWindowTracker(null); options.setAnkiIntegration(null); @@ -51,6 +53,7 @@ test('build initialize overlay runtime options maps dependencies', () => { 'register-shortcuts', 'update-visible-bounds', 'update-visible', + 'refresh-subtitle', 'sync-shortcuts', 'set-tracker', 'set-anki', diff --git a/src/main/runtime/overlay-runtime-options.ts b/src/main/runtime/overlay-runtime-options.ts index ce51c3f1..4ba31141 100644 --- a/src/main/runtime/overlay-runtime-options.ts +++ b/src/main/runtime/overlay-runtime-options.ts @@ -14,6 +14,7 @@ type OverlayRuntimeOptions = { updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; isVisibleOverlayVisible: () => boolean; updateVisibleOverlayVisibility: () => void; + refreshCurrentSubtitle?: () => void; getOverlayWindows: () => BrowserWindow[]; syncOverlayShortcuts: () => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void; @@ -35,6 +36,8 @@ type OverlayRuntimeOptions = { ) => Promise; getKnownWordCacheStatePath: () => string; shouldStartAnkiIntegration: () => boolean; + bindOverlayOwner?: () => void; + releaseOverlayOwner?: () => void; }; export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { @@ -44,6 +47,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { updateVisibleOverlayBounds: (geometry: WindowGeometry) => void; isVisibleOverlayVisible: () => boolean; updateVisibleOverlayVisibility: () => void; + refreshCurrentSubtitle?: () => void; getOverlayWindows: () => BrowserWindow[]; syncOverlayShortcuts: () => void; setWindowTracker: (tracker: BaseWindowTracker | null) => void; @@ -65,6 +69,8 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { ) => Promise; getKnownWordCacheStatePath: () => string; shouldStartAnkiIntegration: () => boolean; + bindOverlayOwner?: () => void; + releaseOverlayOwner?: () => void; }) { return (): OverlayRuntimeOptions => ({ backendOverride: deps.getBackendOverride(), @@ -73,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { updateVisibleOverlayBounds: deps.updateVisibleOverlayBounds, isVisibleOverlayVisible: deps.isVisibleOverlayVisible, updateVisibleOverlayVisibility: deps.updateVisibleOverlayVisibility, + refreshCurrentSubtitle: deps.refreshCurrentSubtitle, getOverlayWindows: deps.getOverlayWindows, syncOverlayShortcuts: deps.syncOverlayShortcuts, setWindowTracker: deps.setWindowTracker, @@ -87,5 +94,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: { createFieldGroupingCallback: deps.createFieldGroupingCallback, getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration, + bindOverlayOwner: deps.bindOverlayOwner, + releaseOverlayOwner: deps.releaseOverlayOwner, }); } diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts index e281691c..eee7c1fd 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts @@ -16,6 +16,9 @@ test('overlay visibility runtime main deps builder maps state and geometry callb getVisibleOverlayVisible: () => true, getForceMousePassthrough: () => true, getWindowTracker: () => tracker, + getLastKnownWindowsForegroundProcessName: () => 'mpv', + getWindowsOverlayProcessName: () => 'subminer', + getWindowsFocusHandoffGraceActive: () => true, getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown, setTrackerNotReadyWarningShown: (shown) => { trackerNotReadyWarningShown = shown; @@ -23,6 +26,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb }, updateVisibleOverlayBounds: () => calls.push('visible-bounds'), ensureOverlayWindowLevel: () => calls.push('ensure-level'), + syncWindowsOverlayToMpvZOrder: () => calls.push('sync-windows-z-order'), syncPrimaryOverlayWindowLayer: (layer) => calls.push(`primary-layer:${layer}`), enforceOverlayLayerOrder: () => calls.push('enforce-order'), syncOverlayShortcuts: () => calls.push('sync-shortcuts'), @@ -36,10 +40,14 @@ test('overlay visibility runtime main deps builder maps state and geometry callb assert.equal(deps.getModalActive(), true); assert.equal(deps.getVisibleOverlayVisible(), true); assert.equal(deps.getForceMousePassthrough(), true); + assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv'); + assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer'); + assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true); assert.equal(deps.getTrackerNotReadyWarningShown(), false); deps.setTrackerNotReadyWarningShown(true); deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 }); deps.ensureOverlayWindowLevel(mainWindow); + deps.syncWindowsOverlayToMpvZOrder?.(mainWindow); deps.syncPrimaryOverlayWindowLayer('visible'); deps.enforceOverlayLayerOrder(); deps.syncOverlayShortcuts(); @@ -52,6 +60,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb 'tracker-warning:true', 'visible-bounds', 'ensure-level', + 'sync-windows-z-order', 'primary-layer:visible', 'enforce-order', 'sync-shortcuts', diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts index 2d3063db..f4b7761f 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts @@ -11,11 +11,17 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler( getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), getForceMousePassthrough: () => deps.getForceMousePassthrough(), getWindowTracker: () => deps.getWindowTracker(), + getLastKnownWindowsForegroundProcessName: () => + deps.getLastKnownWindowsForegroundProcessName?.() ?? null, + getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null, + getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false, getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(), setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown), updateVisibleOverlayBounds: (geometry: WindowGeometry) => deps.updateVisibleOverlayBounds(geometry), ensureOverlayWindowLevel: (window: BrowserWindow) => deps.ensureOverlayWindowLevel(window), + syncWindowsOverlayToMpvZOrder: (window: BrowserWindow) => + deps.syncWindowsOverlayToMpvZOrder?.(window), syncPrimaryOverlayWindowLayer: (layer: 'visible') => deps.syncPrimaryOverlayWindowLayer(layer), enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(), syncOverlayShortcuts: () => deps.syncOverlayShortcuts(), diff --git a/src/main/runtime/overlay-window-factory-main-deps.ts b/src/main/runtime/overlay-window-factory-main-deps.ts index 881289de..45633461 100644 --- a/src/main/runtime/overlay-window-factory-main-deps.ts +++ b/src/main/runtime/overlay-window-factory-main-deps.ts @@ -11,6 +11,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; + onVisibleWindowBlurred?: () => void; + onWindowContentReady?: () => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void; yomitanSession?: Session | null; }, @@ -22,6 +24,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; + onVisibleWindowBlurred?: () => void; + onWindowContentReady?: () => void; onWindowClosed: (windowKind: 'visible' | 'modal') => void; getYomitanSession?: () => Session | null; }) { @@ -34,6 +38,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler(deps: { isOverlayVisible: deps.isOverlayVisible, tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, forwardTabToMpv: deps.forwardTabToMpv, + onVisibleWindowBlurred: deps.onVisibleWindowBlurred, + onWindowContentReady: deps.onWindowContentReady, onWindowClosed: deps.onWindowClosed, getYomitanSession: () => deps.getYomitanSession?.() ?? null, }); diff --git a/src/main/runtime/overlay-window-factory.ts b/src/main/runtime/overlay-window-factory.ts index 9219ad11..d1c71491 100644 --- a/src/main/runtime/overlay-window-factory.ts +++ b/src/main/runtime/overlay-window-factory.ts @@ -13,6 +13,8 @@ export function createCreateOverlayWindowHandler(deps: { isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; + onVisibleWindowBlurred?: () => void; + onWindowContentReady?: () => void; onWindowClosed: (windowKind: OverlayWindowKind) => void; yomitanSession?: Session | null; }, @@ -24,6 +26,8 @@ export function createCreateOverlayWindowHandler(deps: { isOverlayVisible: (windowKind: OverlayWindowKind) => boolean; tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean; forwardTabToMpv: () => void; + onVisibleWindowBlurred?: () => void; + onWindowContentReady?: () => void; onWindowClosed: (windowKind: OverlayWindowKind) => void; getYomitanSession?: () => Session | null; }) { @@ -36,6 +40,8 @@ export function createCreateOverlayWindowHandler(deps: { isOverlayVisible: deps.isOverlayVisible, tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback, forwardTabToMpv: deps.forwardTabToMpv, + onVisibleWindowBlurred: deps.onVisibleWindowBlurred, + onWindowContentReady: deps.onWindowContentReady, onWindowClosed: deps.onWindowClosed, yomitanSession: deps.getYomitanSession?.() ?? null, }); diff --git a/src/main/runtime/overlay-window-layout-main-deps.ts b/src/main/runtime/overlay-window-layout-main-deps.ts index d5440214..f8e70ebc 100644 --- a/src/main/runtime/overlay-window-layout-main-deps.ts +++ b/src/main/runtime/overlay-window-layout-main-deps.ts @@ -15,6 +15,7 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler( ) { return (): UpdateVisibleOverlayBoundsMainDeps => ({ setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry), + afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry), }); } diff --git a/src/main/runtime/overlay-window-layout.test.ts b/src/main/runtime/overlay-window-layout.test.ts index b1c281fc..84a7be43 100644 --- a/src/main/runtime/overlay-window-layout.test.ts +++ b/src/main/runtime/overlay-window-layout.test.ts @@ -16,6 +16,22 @@ test('visible bounds handler writes visible layer geometry', () => { assert.deepEqual(calls, [geometry]); }); +test('visible bounds handler runs follow-up callback after applying geometry', () => { + const calls: string[] = []; + const geometry = { x: 0, y: 0, width: 100, height: 50 }; + const handleVisible = createUpdateVisibleOverlayBoundsHandler({ + setOverlayWindowBounds: () => calls.push('set-bounds'), + afterSetOverlayWindowBounds: (nextGeometry) => { + assert.deepEqual(nextGeometry, geometry); + calls.push('after-bounds'); + }, + }); + + handleVisible(geometry); + + assert.deepEqual(calls, ['set-bounds', 'after-bounds']); +}); + test('ensure overlay window level handler delegates to core', () => { const calls: string[] = []; const ensureLevel = createEnsureOverlayWindowLevelHandler({ diff --git a/src/main/runtime/overlay-window-layout.ts b/src/main/runtime/overlay-window-layout.ts index f14da487..d1d0330a 100644 --- a/src/main/runtime/overlay-window-layout.ts +++ b/src/main/runtime/overlay-window-layout.ts @@ -2,9 +2,11 @@ import type { WindowGeometry } from '../../types'; export function createUpdateVisibleOverlayBoundsHandler(deps: { setOverlayWindowBounds: (geometry: WindowGeometry) => void; + afterSetOverlayWindowBounds?: (geometry: WindowGeometry) => void; }) { return (geometry: WindowGeometry): void => { deps.setOverlayWindowBounds(geometry); + deps.afterSetOverlayWindowBounds?.(geometry); }; } diff --git a/src/main/runtime/playlist-browser-open.test.ts b/src/main/runtime/playlist-browser-open.test.ts index 970d10e9..9fe9ef4e 100644 --- a/src/main/runtime/playlist-browser-open.test.ts +++ b/src/main/runtime/playlist-browser-open.test.ts @@ -3,10 +3,10 @@ import test from 'node:test'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { openPlaylistBrowser } from './playlist-browser-open'; -test('playlist browser open bootstraps overlay runtime before dispatching the modal event', () => { +test('playlist browser open bootstraps overlay runtime and sends modal event with preferModalWindow', async () => { const calls: string[] = []; - const opened = openPlaylistBrowser({ + const opened = await openPlaylistBrowser({ ensureOverlayStartupPrereqs: () => { calls.push('prereqs'); }, @@ -18,11 +18,31 @@ test('playlist browser open bootstraps overlay runtime before dispatching the mo assert.equal(payload, undefined); assert.deepEqual(runtimeOptions, { restoreOnModalClose: 'playlist-browser', + preferModalWindow: true, }); return true; }, + waitForModalOpen: async () => true, + logWarn: () => {}, }); assert.equal(opened, true); assert.deepEqual(calls, ['prereqs', 'windows', `send:${IPC_CHANNELS.event.playlistBrowserOpen}`]); }); + +test('playlist browser open retries after first attempt timeout', async () => { + let attempt = 0; + const opened = await openPlaylistBrowser({ + ensureOverlayStartupPrereqs: () => {}, + ensureOverlayWindowsReadyForVisibilityActions: () => {}, + sendToActiveOverlayWindow: () => true, + waitForModalOpen: async () => { + attempt += 1; + return attempt >= 2; + }, + logWarn: () => {}, + }); + + assert.equal(opened, true); + assert.equal(attempt, 2); +}); diff --git a/src/main/runtime/playlist-browser-open.ts b/src/main/runtime/playlist-browser-open.ts index ba4ce1f8..db4fe4e0 100644 --- a/src/main/runtime/playlist-browser-open.ts +++ b/src/main/runtime/playlist-browser-open.ts @@ -1,9 +1,11 @@ import type { OverlayHostedModal } from '../../shared/ipc/contracts'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; const PLAYLIST_BROWSER_MODAL: OverlayHostedModal = 'playlist-browser'; +const PLAYLIST_BROWSER_OPEN_TIMEOUT_MS = 1500; -export function openPlaylistBrowser(deps: { +export async function openPlaylistBrowser(deps: { ensureOverlayStartupPrereqs: () => void; ensureOverlayWindowsReadyForVisibilityActions: () => void; sendToActiveOverlayWindow: ( @@ -14,10 +16,33 @@ export function openPlaylistBrowser(deps: { preferModalWindow?: boolean; }, ) => boolean; -}): boolean { - deps.ensureOverlayStartupPrereqs(); - deps.ensureOverlayWindowsReadyForVisibilityActions(); - return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.playlistBrowserOpen, undefined, { - restoreOnModalClose: PLAYLIST_BROWSER_MODAL, - }); + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: PLAYLIST_BROWSER_MODAL, + timeoutMs: PLAYLIST_BROWSER_OPEN_TIMEOUT_MS, + retryWarning: + 'Playlist browser modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: IPC_CHANNELS.event.playlistBrowserOpen, + modal: PLAYLIST_BROWSER_MODAL, + preferModalWindow: true, + }, + ), + }, + ); } diff --git a/src/main/runtime/runtime-options-open.test.ts b/src/main/runtime/runtime-options-open.test.ts new file mode 100644 index 00000000..28ec2ac1 --- /dev/null +++ b/src/main/runtime/runtime-options-open.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { openRuntimeOptionsModal } from './runtime-options-open'; + +test('runtime options open prefers dedicated modal window on first attempt', async () => { + const calls: string[] = []; + + const opened = await openRuntimeOptionsModal({ + ensureOverlayStartupPrereqs: () => { + calls.push('ensure-startup'); + }, + ensureOverlayWindowsReadyForVisibilityActions: () => { + calls.push('ensure-windows'); + }, + sendToActiveOverlayWindow: (channel, payload, options) => { + calls.push(`send:${channel}`); + assert.equal(payload, undefined); + assert.deepEqual(options, { + restoreOnModalClose: 'runtime-options', + preferModalWindow: true, + }); + return true; + }, + waitForModalOpen: async (modal, timeoutMs) => { + assert.equal(modal, 'runtime-options'); + assert.equal(timeoutMs, 1500); + return true; + }, + logWarn: () => { + throw new Error('should not warn on first-attempt success'); + }, + }); + + assert.equal(opened, true); + assert.deepEqual(calls, ['ensure-startup', 'ensure-windows', 'send:runtime-options:open']); +}); + +test('runtime options open retries after an open timeout', async () => { + const calls: string[] = []; + const warnings: string[] = []; + let waitCalls = 0; + + const opened = await openRuntimeOptionsModal({ + ensureOverlayStartupPrereqs: () => { + calls.push('ensure-startup'); + }, + ensureOverlayWindowsReadyForVisibilityActions: () => { + calls.push('ensure-windows'); + }, + sendToActiveOverlayWindow: (channel, payload, options) => { + calls.push(`send:${channel}`); + assert.equal(payload, undefined); + assert.deepEqual(options, { + restoreOnModalClose: 'runtime-options', + preferModalWindow: true, + }); + return true; + }, + waitForModalOpen: async (modal, timeoutMs) => { + assert.equal(modal, 'runtime-options'); + assert.equal(timeoutMs, 1500); + waitCalls += 1; + return waitCalls === 2; + }, + logWarn: (message) => { + warnings.push(message); + }, + }); + + assert.equal(opened, true); + assert.deepEqual(calls, [ + 'ensure-startup', + 'ensure-windows', + 'send:runtime-options:open', + 'ensure-startup', + 'ensure-windows', + 'send:runtime-options:open', + ]); + assert.deepEqual(warnings, [ + 'Runtime options modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + ]); +}); + +test('runtime options open fails when no overlay window can be targeted', async () => { + let waitCalls = 0; + const opened = await openRuntimeOptionsModal({ + ensureOverlayStartupPrereqs: () => {}, + ensureOverlayWindowsReadyForVisibilityActions: () => {}, + sendToActiveOverlayWindow: () => false, + waitForModalOpen: async () => { + waitCalls += 1; + return true; + }, + logWarn: () => {}, + }); + + assert.equal(opened, false); + assert.equal(waitCalls, 0); +}); diff --git a/src/main/runtime/runtime-options-open.ts b/src/main/runtime/runtime-options-open.ts new file mode 100644 index 00000000..bf73d896 --- /dev/null +++ b/src/main/runtime/runtime-options-open.ts @@ -0,0 +1,47 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +const RUNTIME_OPTIONS_MODAL: OverlayHostedModal = 'runtime-options'; +const RUNTIME_OPTIONS_OPEN_TIMEOUT_MS = 1500; + +export async function openRuntimeOptionsModal(deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: RUNTIME_OPTIONS_MODAL, + timeoutMs: RUNTIME_OPTIONS_OPEN_TIMEOUT_MS, + retryWarning: + 'Runtime options modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: 'runtime-options:open', + modal: RUNTIME_OPTIONS_MODAL, + preferModalWindow: true, + }, + ), + }, + ); +} diff --git a/src/main/runtime/session-bindings-artifact.ts b/src/main/runtime/session-bindings-artifact.ts new file mode 100644 index 00000000..16858147 --- /dev/null +++ b/src/main/runtime/session-bindings-artifact.ts @@ -0,0 +1,17 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { PluginSessionBindingsArtifact } from '../../types'; + +export function getSessionBindingsArtifactPath(configDir: string): string { + return path.join(configDir, 'session-bindings.json'); +} + +export function writeSessionBindingsArtifact( + configDir: string, + artifact: PluginSessionBindingsArtifact, +): string { + const artifactPath = getSessionBindingsArtifactPath(configDir); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8'); + return artifactPath; +} diff --git a/src/main/runtime/session-help-open.ts b/src/main/runtime/session-help-open.ts new file mode 100644 index 00000000..86b7b59f --- /dev/null +++ b/src/main/runtime/session-help-open.ts @@ -0,0 +1,48 @@ +import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts'; +import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; + +const SESSION_HELP_MODAL: OverlayHostedModal = 'session-help'; +const SESSION_HELP_OPEN_TIMEOUT_MS = 1500; + +export async function openSessionHelpModal(deps: { + ensureOverlayStartupPrereqs: () => void; + ensureOverlayWindowsReadyForVisibilityActions: () => void; + sendToActiveOverlayWindow: ( + channel: string, + payload?: unknown, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, + ) => boolean; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; + logWarn: (message: string) => void; +}): Promise { + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: SESSION_HELP_MODAL, + timeoutMs: SESSION_HELP_OPEN_TIMEOUT_MS, + retryWarning: + 'Session help modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + openOverlayHostedModal( + { + ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs, + ensureOverlayWindowsReadyForVisibilityActions: + deps.ensureOverlayWindowsReadyForVisibilityActions, + sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, + }, + { + channel: IPC_CHANNELS.event.sessionHelpOpen, + modal: SESSION_HELP_MODAL, + preferModalWindow: true, + }, + ), + }, + ); +} diff --git a/src/main/runtime/youtube-picker-open.ts b/src/main/runtime/youtube-picker-open.ts index fe231fe2..aa1b593e 100644 --- a/src/main/runtime/youtube-picker-open.ts +++ b/src/main/runtime/youtube-picker-open.ts @@ -1,5 +1,6 @@ import type { YoutubePickerOpenPayload } from '../../types'; import type { OverlayHostedModal } from '../../shared/ipc/contracts'; +import { retryOverlayModalOpen } from './overlay-hosted-modal-open'; const YOUTUBE_PICKER_MODAL: OverlayHostedModal = 'youtube-track-picker'; const YOUTUBE_PICKER_OPEN_TIMEOUT_MS = 1500; @@ -19,24 +20,21 @@ export async function openYoutubeTrackPicker( }, payload: YoutubePickerOpenPayload, ): Promise { - const sendPickerOpen = (): boolean => - deps.sendToActiveOverlayWindow('youtube:picker-open', payload, { - restoreOnModalClose: YOUTUBE_PICKER_MODAL, - preferModalWindow: true, - }); - - if (!sendPickerOpen()) { - return false; - } - if (await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS)) { - return true; - } - - deps.logWarn( - 'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.', + return await retryOverlayModalOpen( + { + waitForModalOpen: deps.waitForModalOpen, + logWarn: deps.logWarn, + }, + { + modal: YOUTUBE_PICKER_MODAL, + timeoutMs: YOUTUBE_PICKER_OPEN_TIMEOUT_MS, + retryWarning: + 'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying dedicated modal window.', + sendOpen: () => + deps.sendToActiveOverlayWindow('youtube:picker-open', payload, { + restoreOnModalClose: YOUTUBE_PICKER_MODAL, + preferModalWindow: true, + }), + }, ); - if (!sendPickerOpen()) { - return false; - } - return await deps.waitForModalOpen(YOUTUBE_PICKER_MODAL, YOUTUBE_PICKER_OPEN_TIMEOUT_MS); } diff --git a/src/main/state.test.ts b/src/main/state.test.ts index 6ddf7f34..295ba67d 100644 --- a/src/main/state.test.ts +++ b/src/main/state.test.ts @@ -113,3 +113,12 @@ test('applyStartupState preserves cleared startup-only runtime flags', () => { assert.equal(appState.initialArgs?.settings, true); }); + +test('createAppState starts with session bindings marked uninitialized', () => { + const appState = createAppState({ + mpvSocketPath: '/tmp/mpv.sock', + texthookerPort: 4000, + }); + + assert.equal(appState.sessionBindingsInitialized, false); +}); diff --git a/src/main/state.ts b/src/main/state.ts index 16372dd2..af3408be 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -1,6 +1,7 @@ import type { BrowserWindow, Extension, Session } from 'electron'; import type { + CompiledSessionBinding, Keybinding, MpvSubtitleRenderMetrics, SecondarySubMode, @@ -170,6 +171,8 @@ export interface AppState { anilistClientSecretState: AnilistSecretResolutionState; mecabTokenizer: MecabTokenizer | null; keybindings: Keybinding[]; + sessionBindings: CompiledSessionBinding[]; + sessionBindingsInitialized: boolean; subtitleTimingTracker: SubtitleTimingTracker | null; immersionTracker: ImmersionTrackerService | null; ankiIntegration: AnkiIntegration | null; @@ -252,6 +255,8 @@ export function createAppState(values: AppStateInitialValues): AppState { anilistClientSecretState: createInitialAnilistSecretResolutionState(), mecabTokenizer: null, keybindings: [], + sessionBindings: [], + sessionBindingsInitialized: false, subtitleTimingTracker: null, immersionTracker: null, ankiIntegration: null, diff --git a/src/preload.ts b/src/preload.ts index bc112f6a..7bb846bb 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -123,6 +123,9 @@ function createQueuedIpcListenerWithPayload( } const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen); +const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen); +const onOpenControllerSelectEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerSelectOpen); +const onOpenControllerDebugEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerDebugOpen); const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen); const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload( IPC_CHANNELS.event.youtubePickerOpen, @@ -142,6 +145,9 @@ const onSubsyncManualOpenEvent = createQueuedIpcListenerWithPayload payload as SubsyncManualPayload, ); +const onSubtitleSidebarToggleEvent = createQueuedIpcListener( + IPC_CHANNELS.event.subtitleSidebarToggle, +); const onKikuFieldGroupingRequestEvent = createQueuedIpcListenerWithPayload( IPC_CHANNELS.event.kikuFieldGroupingRequest, @@ -223,8 +229,11 @@ const electronAPI: ElectronAPI = { getKeybindings: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings), + getSessionBindings: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSessionBindings), getConfiguredShortcuts: (): Promise> => ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), + dispatchSessionAction: (actionId, payload) => + ipcRenderer.invoke(IPC_CHANNELS.command.dispatchSessionAction, { actionId, payload }), getStatsToggleKey: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey), getMarkWatchedKey: (): Promise => @@ -323,9 +332,13 @@ const electronAPI: ElectronAPI = { ); }, onOpenRuntimeOptions: onOpenRuntimeOptionsEvent, + onOpenSessionHelp: onOpenSessionHelpEvent, + onOpenControllerSelect: onOpenControllerSelectEvent, + onOpenControllerDebug: onOpenControllerDebugEvent, onOpenJimaku: onOpenJimakuEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, + onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, diff --git a/src/prerelease-workflow.test.ts b/src/prerelease-workflow.test.ts new file mode 100644 index 00000000..734c04bb --- /dev/null +++ b/src/prerelease-workflow.test.ts @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml'); +const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8').replace(/\r\n/g, '\n'); +const packageJsonPath = resolve(__dirname, '../package.json'); +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + scripts: Record; +}; + +test('prerelease workflow triggers on beta and rc tags only', () => { + assert.match(prereleaseWorkflow, /name: Prerelease/); + const tagsBlock = prereleaseWorkflow.match(/tags:\s*\n((?:\s*-\s*'[^']+'\s*\n?)+)/); + assert.ok(tagsBlock, 'Workflow tags block not found'); + const tagsText = tagsBlock[1]; + assert.ok(tagsText, 'Workflow tags entries not found'); + const tagPatterns = [...tagsText.matchAll(/-\s*'([^']+)'/g)].map(([, pattern]) => pattern); + assert.deepEqual(tagPatterns, ['v*-beta.*', 'v*-rc.*']); +}); + +test('package scripts expose prerelease notes generation separately from stable changelog build', () => { + assert.equal( + packageJson.scripts['changelog:prerelease-notes'], + 'bun run scripts/build-changelog.ts prerelease-notes', + ); +}); + +test('prerelease workflow generates prerelease notes from pending fragments', () => { + assert.match(prereleaseWorkflow, /bun run changelog:prerelease-notes --version/); + assert.doesNotMatch(prereleaseWorkflow, /bun run changelog:build --version/); +}); + +test('prerelease workflow includes the environment suite in the gate sequence', () => { + assert.match( + prereleaseWorkflow, + /Test suite \(source\)\n\s*run: bun run test:fast\n\s*\n\s*- name: Environment suite(?: \(source\))?\n\s*run: bun run test:env\n\s*\n\s*- name: Coverage suite \(maintained source lane\)/, + ); +}); + +test('prerelease workflow publishes GitHub prereleases and keeps them off latest', () => { + assert.match(prereleaseWorkflow, /gh release edit[\s\S]*--prerelease/); + assert.match(prereleaseWorkflow, /gh release create[\s\S]*--prerelease/); + assert.match(prereleaseWorkflow, /gh release create[\s\S]*--latest=false/); +}); + +test('prerelease workflow scopes dependency caches by runner architecture', () => { + const archScopedCacheKeyMatches = prereleaseWorkflow.match( + /key:\s*\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g, + ); + const archScopedRestoreKeyMatches = prereleaseWorkflow.match( + /\${{\s*runner\.os\s*}}-\${{\s*runner\.arch\s*}}-bun-/g, + ); + assert.equal(archScopedCacheKeyMatches?.length, 5); + assert.ok((archScopedRestoreKeyMatches?.length ?? 0) >= 10); +}); + +test('prerelease workflow builds and uploads all release platforms', () => { + assert.match(prereleaseWorkflow, /build-linux:/); + assert.match(prereleaseWorkflow, /build-macos:/); + assert.match(prereleaseWorkflow, /build-windows:/); + assert.match(prereleaseWorkflow, /name: appimage/); + assert.match(prereleaseWorkflow, /name: macos/); + assert.match(prereleaseWorkflow, /name: windows/); +}); + +test('prerelease workflow publishes the same release assets as the stable workflow', () => { + assert.match( + prereleaseWorkflow, + /files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/, + ); + assert.match( + prereleaseWorkflow, + /artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/, + ); +}); + +test('prerelease workflow writes checksum entries using release asset basenames', () => { + assert.match(prereleaseWorkflow, /: > release\/SHA256SUMS\.txt/); + assert.match(prereleaseWorkflow, /for file in "\$\{files\[@\]\}"; do/); + assert.match(prereleaseWorkflow, /\$\{file##\*\/\}/); + assert.doesNotMatch(prereleaseWorkflow, /sha256sum "\$\{files\[@\]\}" > release\/SHA256SUMS\.txt/); +}); + +test('prerelease workflow validates artifacts before publishing the release and only undrafts after upload', () => { + const artifactsIndex = prereleaseWorkflow.indexOf('artifacts=('); + const createIndex = prereleaseWorkflow.indexOf('gh release create'); + const uploadIndex = prereleaseWorkflow.indexOf('gh release upload'); + const undraftIndex = prereleaseWorkflow.indexOf('--draft=false'); + + assert.notEqual(artifactsIndex, -1); + assert.notEqual(createIndex, -1); + assert.notEqual(uploadIndex, -1); + assert.notEqual(undraftIndex, -1); + assert.ok(artifactsIndex < createIndex); + assert.ok(uploadIndex < undraftIndex); + assert.match(prereleaseWorkflow, /gh release create[\s\S]*--draft[\s\S]*--prerelease/); +}); + +test('prerelease workflow does not publish to AUR', () => { + assert.doesNotMatch(prereleaseWorkflow, /aur-publish:/); + assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/); + assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/); +}); diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index cfe5a82d..951e2978 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -22,6 +22,12 @@ test('publish release leaves prerelease unset so gh creates a normal release', ( assert.ok(!releaseWorkflow.includes('--prerelease')); }); +test('stable release workflow excludes prerelease beta and rc tags', () => { + assert.match(releaseWorkflow, /tags:\s*\n\s*-\s*'v\*'/); + assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-beta\.\*'/); + assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-rc\.\*'/); +}); + test('publish release forces an existing draft tag release to become public', () => { assert.ok(releaseWorkflow.includes('--draft=false')); }); @@ -71,6 +77,13 @@ test('release workflow includes the Windows installer in checksums and uploaded ); }); +test('release workflow writes checksum entries using release asset basenames', () => { + assert.match(releaseWorkflow, /: > release\/SHA256SUMS\.txt/); + assert.match(releaseWorkflow, /for file in "\$\{files\[@\]\}"; do/); + assert.match(releaseWorkflow, /\$\{file##\*\/\}/); + assert.doesNotMatch(releaseWorkflow, /sha256sum "\$\{files\[@\]\}" > release\/SHA256SUMS\.txt/); +}); + test('release package scripts disable implicit electron-builder publishing', () => { assert.match(packageJson.scripts['build:appimage'] ?? '', /--publish never/); assert.match(packageJson.scripts['build:mac'] ?? '', /--publish never/); diff --git a/src/renderer/error-recovery.test.ts b/src/renderer/error-recovery.test.ts index d282b66d..dabd10e0 100644 --- a/src/renderer/error-recovery.test.ts +++ b/src/renderer/error-recovery.test.ts @@ -3,7 +3,9 @@ import assert from 'node:assert/strict'; import { createRendererRecoveryController } from './error-recovery.js'; import { + YOMITAN_POPUP_HOST_SELECTOR, YOMITAN_POPUP_IFRAME_SELECTOR, + YOMITAN_POPUP_VISIBLE_HOST_SELECTOR, hasYomitanPopupIframe, isYomitanPopupIframe, isYomitanPopupVisible, @@ -228,6 +230,42 @@ test('resolvePlatformInfo supports modal layer and disables mouse-ignore toggles } }); +test('resolvePlatformInfo flags Windows platforms', () => { + const previousWindow = (globalThis as { window?: unknown }).window; + const previousNavigator = (globalThis as { navigator?: unknown }).navigator; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + getOverlayLayer: () => 'visible', + }, + location: { search: '' }, + }, + }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + platform: 'Win32', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }, + }); + + try { + const info = resolvePlatformInfo(); + assert.equal(info.isWindowsPlatform, true); + assert.equal(info.isMacOSPlatform, false); + assert.equal(info.isLinuxPlatform, false); + assert.equal(info.shouldToggleMouseIgnore, true); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: previousNavigator, + }); + } +}); + test('isYomitanPopupIframe matches modern popup class and legacy id prefix', () => { const createElement = (options: { tagName: string; @@ -284,9 +322,25 @@ test('hasYomitanPopupIframe queries for modern + legacy selector', () => { assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR); }); +test('hasYomitanPopupIframe falls back to popup host selector for shadow-hosted popups', () => { + const selectors: string[] = []; + const root = { + querySelector: (value: string) => { + selectors.push(value); + if (value === YOMITAN_POPUP_HOST_SELECTOR) { + return {}; + } + return null; + }, + } as unknown as ParentNode; + + assert.equal(hasYomitanPopupIframe(root), true); + assert.deepEqual(selectors, [YOMITAN_POPUP_IFRAME_SELECTOR, YOMITAN_POPUP_HOST_SELECTOR]); +}); + test('isYomitanPopupVisible requires visible iframe geometry', () => { const previousWindow = (globalThis as { window?: unknown }).window; - let selector = ''; + const selectors: string[] = []; const visibleFrame = { getBoundingClientRect: () => ({ width: 320, height: 180 }), } as unknown as HTMLIFrameElement; @@ -309,18 +363,40 @@ test('isYomitanPopupVisible requires visible iframe geometry', () => { try { const root = { querySelectorAll: (value: string) => { - selector = value; + selectors.push(value); + if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || value === YOMITAN_POPUP_HOST_SELECTOR) { + return []; + } return [hiddenFrame, visibleFrame]; }, } as unknown as ParentNode; assert.equal(isYomitanPopupVisible(root), true); - assert.equal(selector, YOMITAN_POPUP_IFRAME_SELECTOR); + assert.deepEqual(selectors, [ + YOMITAN_POPUP_VISIBLE_HOST_SELECTOR, + YOMITAN_POPUP_IFRAME_SELECTOR, + ]); } finally { Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); } }); +test('isYomitanPopupVisible detects visible shadow-hosted popup marker without iframe access', () => { + let selector = ''; + const root = { + querySelectorAll: (value: string) => { + selector = value; + if (value === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) { + return [{ getAttribute: () => 'true' }]; + } + return []; + }, + } as unknown as ParentNode; + + assert.equal(isYomitanPopupVisible(root), true); + assert.equal(selector, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR); +}); + test('scrollActiveRuntimeOptionIntoView scrolls active runtime option with nearest block', () => { const calls: Array<{ block?: ScrollLogicalPosition }> = []; const activeItem = { diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index d81b7c6f..63999d80 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { createKeyboardHandlers } from './keyboard.js'; import { createRendererState } from '../state.js'; +import type { CompiledSessionBinding } from '../../types'; import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js'; type CommandEventDetail = { @@ -50,6 +51,8 @@ function installKeyboardTestGlobals() { const windowListeners = new Map void>>(); const commandEvents: CommandEventDetail[] = []; const mpvCommands: Array> = []; + const sessionActions: Array<{ actionId: string; payload?: unknown }> = []; + let sessionBindings: CompiledSessionBinding[] = []; let playbackPausedResponse: boolean | null = false; let statsToggleKey = 'Backquote'; let markWatchedKey = 'KeyW'; @@ -66,11 +69,16 @@ function installKeyboardTestGlobals() { markAudioCard: '', openRuntimeOptions: 'CommandOrControl+Shift+O', openJimaku: 'Ctrl+Shift+J', + openSessionHelp: 'CommandOrControl+Shift+H', + openControllerSelect: 'Alt+C', + openControllerDebug: 'Alt+Shift+C', + toggleSubtitleSidebar: '', toggleVisibleOverlayGlobal: '', }; let markActiveVideoWatchedResult = true; let markActiveVideoWatchedCalls = 0; let statsToggleOverlayCalls = 0; + const openedModalNotifications: string[] = []; let selectionClearCount = 0; let selectionAddCount = 0; @@ -153,10 +161,14 @@ function installKeyboardTestGlobals() { }, electronAPI: { getKeybindings: async () => [], + getSessionBindings: async () => sessionBindings, getConfiguredShortcuts: async () => configuredShortcuts, sendMpvCommand: (command: Array) => { mpvCommands.push(command); }, + dispatchSessionAction: async (actionId: string, payload?: unknown) => { + sessionActions.push({ actionId, payload }); + }, getPlaybackPaused: async () => playbackPausedResponse, getStatsToggleKey: async () => statsToggleKey, getMarkWatchedKey: async () => markWatchedKey, @@ -172,6 +184,9 @@ function installKeyboardTestGlobals() { focusMainWindowCalls += 1; return Promise.resolve(); }, + notifyOverlayModalOpened: (modal: string) => { + openedModalNotifications.push(modal); + }, }, }, }); @@ -273,6 +288,7 @@ function installKeyboardTestGlobals() { return { commandEvents, mpvCommands, + sessionActions, overlay, overlayFocusCalls, focusMainWindowCalls: () => focusMainWindowCalls, @@ -292,11 +308,15 @@ function installKeyboardTestGlobals() { setConfiguredShortcuts: (value: typeof configuredShortcuts) => { configuredShortcuts = value; }, + setSessionBindings: (value: CompiledSessionBinding[]) => { + sessionBindings = value; + }, setMarkActiveVideoWatchedResult: (value: boolean) => { markActiveVideoWatchedResult = value; }, markActiveVideoWatchedCalls: () => markActiveVideoWatchedCalls, statsToggleOverlayCalls: () => statsToggleOverlayCalls, + openedModalNotifications, getPlaybackPaused: async () => playbackPausedResponse, setPlaybackPausedResponse: (value: boolean | null) => { playbackPausedResponse = value; @@ -310,9 +330,9 @@ function installKeyboardTestGlobals() { function createKeyboardHandlerHarness() { const testGlobals = installKeyboardTestGlobals(); const subtitleRootClassList = createClassList(); - let controllerSelectOpenCount = 0; - let controllerDebugOpenCount = 0; let controllerSelectKeydownCount = 0; + let openControllerSelectCount = 0; + let openControllerDebugCount = 0; let playlistBrowserKeydownCount = 0; const createWordNode = (left: number) => ({ @@ -360,23 +380,23 @@ function createKeyboardHandlerHarness() { }, handleSessionHelpKeydown: () => false, openSessionHelpModal: () => {}, - appendClipboardVideoToQueue: () => {}, - getPlaybackPaused: () => testGlobals.getPlaybackPaused(), openControllerSelectModal: () => { - controllerSelectOpenCount += 1; + openControllerSelectCount += 1; }, openControllerDebugModal: () => { - controllerDebugOpenCount += 1; + openControllerDebugCount += 1; }, + appendClipboardVideoToQueue: () => {}, + getPlaybackPaused: () => testGlobals.getPlaybackPaused(), }); return { ctx, handlers, testGlobals, - controllerSelectOpenCount: () => controllerSelectOpenCount, - controllerDebugOpenCount: () => controllerDebugOpenCount, controllerSelectKeydownCount: () => controllerSelectKeydownCount, + openControllerSelectCount: () => openControllerSelectCount, + openControllerDebugCount: () => openControllerDebugCount, playlistBrowserKeydownCount: () => playlistBrowserKeydownCount, setWordCount: (count: number) => { wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70)); @@ -384,6 +404,88 @@ function createKeyboardHandlerHarness() { }; } +test('session help chord resolver follows remapped session bindings', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + + assert.deepEqual(handlers.getSessionHelpOpeningInfo(), { + bindingKey: 'KeyH', + fallbackUsed: false, + fallbackUnavailable: false, + }); + + handlers.updateSessionBindings([ + { + sourcePath: 'keybindings[0].key', + originalKey: 'KeyH', + key: { code: 'KeyH', modifiers: [] }, + actionType: 'session-action', + actionId: 'openJimaku', + }, + { + sourcePath: 'keybindings[1].key', + originalKey: 'KeyJ', + key: { code: 'KeyJ', modifiers: [] }, + actionType: 'mpv-command', + command: ['cycle', 'pause'], + }, + ] as never); + + assert.deepEqual(handlers.getSessionHelpOpeningInfo(), { + bindingKey: 'KeyK', + fallbackUsed: true, + fallbackUnavailable: false, + }); + + handlers.updateSessionBindings([ + { + sourcePath: 'keybindings[0].key', + originalKey: 'KeyH', + key: { code: 'KeyH', modifiers: [] }, + actionType: 'session-action', + actionId: 'openSessionHelp', + }, + { + sourcePath: 'keybindings[1].key', + originalKey: 'KeyK', + key: { code: 'KeyK', modifiers: [] }, + actionType: 'session-action', + actionId: 'openControllerSelect', + }, + ] as never); + + assert.deepEqual(handlers.getSessionHelpOpeningInfo(), { + bindingKey: 'KeyK', + fallbackUsed: true, + fallbackUnavailable: true, + }); + } finally { + testGlobals.restore(); + } +}); + +test('numeric selection ignores non-digit keys instead of falling through to other shortcuts', async () => { + const { handlers, testGlobals, ctx } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.beginSessionNumericSelection('copySubtitleMultiple'); + + testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' }); + + assert.equal(ctx.state.chordPending, false); + assert.deepEqual(testGlobals.sessionActions, []); + assert.equal( + testGlobals.commandEvents.some((event) => event.type === 'forwardKeyDown'), + false, + ); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: left and right move token selection while popup remains open', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); @@ -521,13 +623,19 @@ test('popup-visible mpv keybindings still fire for bound keys', async () => { try { await handlers.setupMpvInputForwarding(); - handlers.updateKeybindings([ + handlers.updateSessionBindings([ { - key: 'Space', + sourcePath: 'keybindings[0].key', + originalKey: 'Space', + key: { code: 'Space', modifiers: [] }, + actionType: 'mpv-command', command: ['cycle', 'pause'], }, { - key: 'KeyQ', + sourcePath: 'keybindings[1].key', + originalKey: 'KeyQ', + key: { code: 'KeyQ', modifiers: [] }, + actionType: 'mpv-command', command: ['quit'], }, ] as never); @@ -549,9 +657,12 @@ test('paused configured subtitle-jump keybinding re-applies pause after backward try { await handlers.setupMpvInputForwarding(); - handlers.updateKeybindings([ + handlers.updateSessionBindings([ { - key: 'Shift+KeyH', + sourcePath: 'keybindings[0].key', + originalKey: 'Shift+KeyH', + key: { code: 'KeyH', modifiers: ['shift'] }, + actionType: 'mpv-command', command: ['sub-seek', -1], }, ] as never); @@ -574,9 +685,12 @@ test('configured subtitle-jump keybinding preserves pause when pause state is un try { await handlers.setupMpvInputForwarding(); - handlers.updateKeybindings([ + handlers.updateSessionBindings([ { - key: 'Shift+KeyH', + sourcePath: 'keybindings[0].key', + originalKey: 'Shift+KeyH', + key: { code: 'KeyH', modifiers: ['shift'] }, + actionType: 'mpv-command', command: ['sub-seek', -1], }, ] as never); @@ -614,6 +728,44 @@ test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus', } }); +test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', async () => { + const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + + testGlobals.setConfiguredShortcuts({ + copySubtitle: '', + copySubtitleMultiple: '', + updateLastCardFromClipboard: '', + triggerFieldGrouping: '', + triggerSubsync: 'Ctrl+Alt+S', + mineSentence: '', + mineSentenceMultiple: '', + multiCopyTimeoutMs: 3333, + toggleSecondarySub: '', + markAudioCard: '', + openRuntimeOptions: 'CommandOrControl+Shift+O', + openJimaku: 'Ctrl+Shift+J', + openSessionHelp: 'CommandOrControl+Shift+H', + openControllerSelect: 'Alt+C', + openControllerDebug: 'Alt+Shift+C', + toggleSubtitleSidebar: '', + toggleVisibleOverlayGlobal: '', + }); + testGlobals.setStatsToggleKey(''); + testGlobals.setMarkWatchedKey(''); + + await handlers.refreshConfiguredShortcuts(); + + assert.equal(ctx.state.sessionActionTimeoutMs, 3333); + assert.equal(ctx.state.statsToggleKey, ''); + assert.equal(ctx.state.markWatchedKey, ''); + } finally { + testGlobals.restore(); + } +}); + test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); @@ -636,31 +788,111 @@ test('keyboard mode: controller helpers dispatch popup audio play/cycle and scro } }); -test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => { - const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness(); +test('keyboard mode: configured controller select binding opens locally without dispatching a session action', async () => { + const { testGlobals, handlers, openControllerSelectCount } = createKeyboardHandlerHarness(); try { await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.openControllerSelect', + originalKey: 'Alt+D', + key: { code: 'KeyD', modifiers: ['alt'] }, + actionType: 'session-action', + actionId: 'openControllerSelect', + }, + ] as never); testGlobals.dispatchKeydown({ - key: 'C', - code: 'KeyC', + key: 'd', + code: 'KeyD', altKey: true, - shiftKey: true, }); - assert.equal(controllerDebugOpenCount(), 1); + assert.equal(openControllerSelectCount(), 1); + assert.deepEqual(testGlobals.sessionActions, []); + assert.deepEqual(testGlobals.openedModalNotifications, ['controller-select']); } finally { testGlobals.restore(); } }); -test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => { - const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness(); +test('keyboard mode: configured controller debug binding opens locally without dispatching a session action', async () => { + const { testGlobals, handlers, openControllerDebugCount } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.openControllerDebug', + originalKey: 'Alt+Shift+D', + key: { code: 'KeyD', modifiers: ['alt', 'shift'] }, + actionType: 'session-action', + actionId: 'openControllerDebug', + }, + ] as never); + + testGlobals.dispatchKeydown({ + key: 'D', + code: 'KeyD', + altKey: true, + shiftKey: true, + }); + + assert.equal(openControllerDebugCount(), 1); + assert.deepEqual(testGlobals.sessionActions, []); + assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']); + } finally { + testGlobals.restore(); + } +}); + +test('keyboard mode: configured controller debug binding is not swallowed while popup is visible', async () => { + const { ctx, testGlobals, handlers, openControllerDebugCount } = createKeyboardHandlerHarness(); try { await handlers.setupMpvInputForwarding(); ctx.state.yomitanPopupVisible = true; + testGlobals.setPopupVisible(true); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.openControllerDebug', + originalKey: 'Alt+Shift+D', + key: { code: 'KeyD', modifiers: ['alt', 'shift'] }, + actionType: 'session-action', + actionId: 'openControllerDebug', + }, + ] as never); + + testGlobals.dispatchKeydown({ + key: 'D', + code: 'KeyD', + altKey: true, + shiftKey: true, + }); + + assert.equal(openControllerDebugCount(), 1); + assert.deepEqual(testGlobals.sessionActions, []); + assert.deepEqual(testGlobals.openedModalNotifications, ['controller-debug']); + } finally { + testGlobals.restore(); + } +}); + +test('keyboard mode: former fixed Alt+Shift+C does nothing when controller debug is remapped', async () => { + const { testGlobals, handlers } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.openControllerDebug', + originalKey: 'Alt+Shift+D', + key: { code: 'KeyD', modifiers: ['alt', 'shift'] }, + actionType: 'session-action', + actionId: 'openControllerDebug', + }, + ] as never); testGlobals.dispatchKeydown({ key: 'C', @@ -669,7 +901,7 @@ test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup i shiftKey: true, }); - assert.equal(controllerDebugOpenCount(), 1); + assert.deepEqual(testGlobals.sessionActions, []); } finally { testGlobals.restore(); } @@ -758,18 +990,47 @@ test('keyboard mode: configured stats toggle works even while popup is open', as } }); +test('refreshConfiguredShortcuts updates refreshed stats and mark-watched keys', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); + + try { + await handlers.setupMpvInputForwarding(); + + testGlobals.setStatsToggleKey('KeyG'); + testGlobals.setMarkWatchedKey('KeyM'); + await handlers.refreshConfiguredShortcuts(); + + const beforeMarkWatchedCalls = testGlobals.markActiveVideoWatchedCalls(); + + testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' }); + testGlobals.dispatchKeydown({ key: 'm', code: 'KeyM' }); + await wait(10); + + assert.equal(testGlobals.statsToggleOverlayCalls(), 1); + assert.equal(testGlobals.markActiveVideoWatchedCalls(), beforeMarkWatchedCalls + 1); + } finally { + testGlobals.restore(); + } +}); + test('youtube picker: unhandled keys still dispatch mpv keybindings', async () => { const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); try { await handlers.setupMpvInputForwarding(); - handlers.updateKeybindings([ + handlers.updateSessionBindings([ { - key: 'Space', + sourcePath: 'keybindings[0].key', + originalKey: 'Space', + key: { code: 'Space', modifiers: [] }, + actionType: 'mpv-command', command: ['cycle', 'pause'], }, { - key: 'KeyQ', + sourcePath: 'keybindings[1].key', + originalKey: 'KeyQ', + key: { code: 'KeyQ', modifiers: [] }, + actionType: 'mpv-command', command: ['quit'], }, ] as never); @@ -785,46 +1046,72 @@ test('youtube picker: unhandled keys still dispatch mpv keybindings', async () = } }); -test('linux overlay shortcut: Ctrl+Alt+S dispatches subsync special command locally', async () => { - const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); +test('session binding: Ctrl+Alt+S dispatches subsync action locally', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); try { - ctx.platform.isLinuxPlatform = true; await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.triggerSubsync', + originalKey: 'Ctrl+Alt+S', + key: { code: 'KeyS', modifiers: ['ctrl', 'alt'] }, + actionType: 'session-action', + actionId: 'triggerSubsync', + }, + ] as never); testGlobals.dispatchKeydown({ key: 's', code: 'KeyS', ctrlKey: true, altKey: true }); - assert.deepEqual(testGlobals.mpvCommands, [['__subsync-trigger']]); + assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'triggerSubsync', payload: undefined }]); } finally { testGlobals.restore(); } }); -test('linux overlay shortcut: Ctrl+Shift+J dispatches jimaku special command locally', async () => { - const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); +test('session binding: Ctrl+Shift+J dispatches jimaku action locally', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); try { - ctx.platform.isLinuxPlatform = true; await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.openJimaku', + originalKey: 'Ctrl+Shift+J', + key: { code: 'KeyJ', modifiers: ['ctrl', 'shift'] }, + actionType: 'session-action', + actionId: 'openJimaku', + }, + ] as never); testGlobals.dispatchKeydown({ key: 'J', code: 'KeyJ', ctrlKey: true, shiftKey: true }); - assert.deepEqual(testGlobals.mpvCommands, [['__jimaku-open']]); + assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'openJimaku', payload: undefined }]); } finally { testGlobals.restore(); } }); -test('linux overlay shortcut: CommandOrControl+Shift+O dispatches runtime options locally', async () => { - const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness(); +test('session binding: Ctrl+Shift+O dispatches runtime options locally', async () => { + const { handlers, testGlobals } = createKeyboardHandlerHarness(); try { - ctx.platform.isLinuxPlatform = true; await handlers.setupMpvInputForwarding(); + handlers.updateSessionBindings([ + { + sourcePath: 'shortcuts.openRuntimeOptions', + originalKey: 'CommandOrControl+Shift+O', + key: { code: 'KeyO', modifiers: ['ctrl', 'shift'] }, + actionType: 'session-action', + actionId: 'openRuntimeOptions', + }, + ] as never); testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', ctrlKey: true, shiftKey: true }); - assert.deepEqual(testGlobals.mpvCommands, [['__runtime-options-open']]); + assert.deepEqual(testGlobals.sessionActions, [ + { actionId: 'openRuntimeOptions', payload: undefined }, + ]); } finally { testGlobals.restore(); } diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts index 8d97a3a9..ca99e31c 100644 --- a/src/renderer/handlers/keyboard.ts +++ b/src/renderer/handlers/keyboard.ts @@ -1,5 +1,4 @@ -import { SPECIAL_COMMANDS } from '../../config/definitions'; -import type { Keybinding, ShortcutsConfig } from '../../types'; +import type { CompiledSessionBinding, ShortcutsConfig } from '../../types'; import type { RendererContext } from '../context'; import { YOMITAN_POPUP_HIDDEN_EVENT, @@ -26,21 +25,26 @@ export function createKeyboardHandlers( fallbackUsed: boolean; fallbackUnavailable: boolean; }) => void; + openControllerSelectModal?: () => void; + openControllerDebugModal?: () => void; appendClipboardVideoToQueue: () => void; getPlaybackPaused: () => Promise; - openControllerSelectModal: () => void; - openControllerDebugModal: () => void; toggleSubtitleSidebarModal?: () => void; }, ) { // Timeout for the modal chord capture window (e.g. Y followed by H/K). const CHORD_TIMEOUT_MS = 1000; const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected'; - const linuxOverlayShortcutCommands = new Map(); let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null; let pendingLookupRefreshAfterSubtitleSeek = false; let resetSelectionToStartOnNextSubtitleSync = false; let lookupScanFallbackTimer: ReturnType | null = null; + let pendingNumericSelection: + | { + actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple'; + timeout: ReturnType | null; + } + | null = null; const CHORD_MAP = new Map< string, @@ -76,113 +80,143 @@ export function createKeyboardHandlers( return parts.join('+'); } - function acceleratorToKeyToken(token: string): string | null { - const normalized = token.trim(); - if (!normalized) return null; - if (/^[a-z]$/i.test(normalized)) { - return `Key${normalized.toUpperCase()}`; + function updateConfiguredShortcuts( + shortcuts: Required, + statsToggleKey?: string, + markWatchedKey?: string, + ): void { + ctx.state.sessionActionTimeoutMs = shortcuts.multiCopyTimeoutMs; + if (typeof statsToggleKey === 'string') { + ctx.state.statsToggleKey = statsToggleKey; } - if (/^[0-9]$/.test(normalized)) { - return `Digit${normalized}`; - } - const exactMap: Record = { - space: 'Space', - tab: 'Tab', - enter: 'Enter', - return: 'Enter', - esc: 'Escape', - escape: 'Escape', - up: 'ArrowUp', - down: 'ArrowDown', - left: 'ArrowLeft', - right: 'ArrowRight', - backspace: 'Backspace', - delete: 'Delete', - slash: 'Slash', - backslash: 'Backslash', - minus: 'Minus', - plus: 'Equal', - equal: 'Equal', - comma: 'Comma', - period: 'Period', - quote: 'Quote', - semicolon: 'Semicolon', - bracketleft: 'BracketLeft', - bracketright: 'BracketRight', - backquote: 'Backquote', - }; - const lower = normalized.toLowerCase(); - if (exactMap[lower]) return exactMap[lower]; - if (/^key[a-z]$/i.test(normalized) || /^digit[0-9]$/i.test(normalized)) { - return normalized[0]!.toUpperCase() + normalized.slice(1); - } - if (/^arrow(?:up|down|left|right)$/i.test(normalized)) { - return normalized[0]!.toUpperCase() + normalized.slice(1); - } - if (/^f\d{1,2}$/i.test(normalized)) { - return normalized.toUpperCase(); - } - return null; - } - - function acceleratorToKeyString(accelerator: string): string | null { - const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl'); - if (!normalized) return null; - const parts = normalized.split('+').filter(Boolean); - const keyToken = parts.pop(); - if (!keyToken) return null; - - const eventParts: string[] = []; - for (const modifier of parts) { - const lower = modifier.toLowerCase(); - if (lower === 'ctrl' || lower === 'control') { - eventParts.push('Ctrl'); - continue; - } - if (lower === 'alt' || lower === 'option') { - eventParts.push('Alt'); - continue; - } - if (lower === 'shift') { - eventParts.push('Shift'); - continue; - } - if (lower === 'meta' || lower === 'super' || lower === 'command' || lower === 'cmd') { - eventParts.push('Meta'); - continue; - } - if (lower === 'commandorcontrol') { - eventParts.push(ctx.platform.isMacOSPlatform ? 'Meta' : 'Ctrl'); - continue; - } - return null; - } - - const normalizedKey = acceleratorToKeyToken(keyToken); - if (!normalizedKey) return null; - eventParts.push(normalizedKey); - return eventParts.join('+'); - } - - function updateConfiguredShortcuts(shortcuts: Required): void { - linuxOverlayShortcutCommands.clear(); - const bindings: Array<[string | null, (string | number)[]]> = [ - [shortcuts.triggerSubsync, [SPECIAL_COMMANDS.SUBSYNC_TRIGGER]], - [shortcuts.openRuntimeOptions, [SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN]], - [shortcuts.openJimaku, [SPECIAL_COMMANDS.JIMAKU_OPEN]], - ]; - - for (const [accelerator, command] of bindings) { - if (!accelerator) continue; - const keyString = acceleratorToKeyString(accelerator); - if (keyString) { - linuxOverlayShortcutCommands.set(keyString, command); - } + if (typeof markWatchedKey === 'string') { + ctx.state.markWatchedKey = markWatchedKey; } } async function refreshConfiguredShortcuts(): Promise { - updateConfiguredShortcuts(await window.electronAPI.getConfiguredShortcuts()); + const [shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([ + window.electronAPI.getConfiguredShortcuts(), + window.electronAPI.getStatsToggleKey(), + window.electronAPI.getMarkWatchedKey(), + ]); + updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey); + } + + function updateSessionBindings(bindings: CompiledSessionBinding[]): void { + ctx.state.sessionBindings = bindings; + ctx.state.sessionBindingMap = new Map( + bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]), + ); + } + + function keyEventToStringFromBinding(binding: CompiledSessionBinding): string { + const parts: string[] = []; + for (const modifier of binding.key.modifiers) { + if (modifier === 'ctrl') parts.push('Ctrl'); + else if (modifier === 'alt') parts.push('Alt'); + else if (modifier === 'shift') parts.push('Shift'); + else if (modifier === 'meta') parts.push('Meta'); + } + parts.push(binding.key.code); + return parts.join('+'); + } + + function isTextEntryTarget(target: EventTarget | null): boolean { + if (!target || typeof target !== 'object' || !('closest' in target)) return false; + const element = target as { closest: (selector: string) => unknown }; + if (element.closest('[contenteditable="true"]')) return true; + return Boolean(element.closest('input, textarea, select')); + } + + function showSessionSelectionMessage(message: string): void { + window.electronAPI.sendMpvCommand(['show-text', message, '3000']); + } + + function cancelPendingNumericSelection(showCancelled: boolean): void { + if (!pendingNumericSelection) return; + if (pendingNumericSelection.timeout !== null) { + clearTimeout(pendingNumericSelection.timeout); + } + pendingNumericSelection = null; + if (showCancelled) { + showSessionSelectionMessage('Cancelled'); + } + } + + function startPendingNumericSelection( + actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple', + ): void { + cancelPendingNumericSelection(false); + const timeoutMessage = actionId === 'copySubtitleMultiple' ? 'Copy timeout' : 'Mine timeout'; + const promptMessage = + actionId === 'copySubtitleMultiple' + ? 'Copy how many lines? Press 1-9 (Esc to cancel)' + : 'Mine how many lines? Press 1-9 (Esc to cancel)'; + pendingNumericSelection = { + actionId, + timeout: setTimeout(() => { + pendingNumericSelection = null; + showSessionSelectionMessage(timeoutMessage); + }, ctx.state.sessionActionTimeoutMs), + }; + showSessionSelectionMessage(promptMessage); + } + + function beginSessionNumericSelection( + actionId: 'copySubtitleMultiple' | 'mineSentenceMultiple', + ): void { + startPendingNumericSelection(actionId); + } + + function handlePendingNumericSelection(e: KeyboardEvent): boolean { + if (!pendingNumericSelection) return false; + if (e.key === 'Escape') { + e.preventDefault(); + cancelPendingNumericSelection(true); + return true; + } + + if (!/^[1-9]$/.test(e.key) || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) { + e.preventDefault(); + return true; + } + + e.preventDefault(); + const count = Number(e.key); + const actionId = pendingNumericSelection.actionId; + cancelPendingNumericSelection(false); + void window.electronAPI.dispatchSessionAction(actionId, { count }); + return true; + } + + function dispatchSessionBinding(binding: CompiledSessionBinding): void { + if ( + binding.actionType === 'session-action' && + (binding.actionId === 'copySubtitleMultiple' || binding.actionId === 'mineSentenceMultiple') + ) { + startPendingNumericSelection(binding.actionId); + return; + } + + if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') { + window.electronAPI.notifyOverlayModalOpened('controller-select'); + options.openControllerSelectModal?.(); + return; + } + + if (binding.actionType === 'session-action' && binding.actionId === 'openControllerDebug') { + window.electronAPI.notifyOverlayModalOpened('controller-debug'); + options.openControllerDebugModal?.(); + return; + } + + if (binding.actionType === 'mpv-command') { + dispatchConfiguredMpvCommand(binding.command); + return; + } + + void window.electronAPI.dispatchSessionAction(binding.actionId, binding.payload); } function dispatchYomitanPopupKeydown( @@ -292,10 +326,6 @@ export function createKeyboardHandlers( return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat; } - function isControllerModalShortcut(e: KeyboardEvent): boolean { - return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC'; - } - function isSubtitleSidebarToggle(e: KeyboardEvent): boolean { const toggleKey = ctx.state.subtitleSidebarToggleKey; if (!toggleKey) return false; @@ -508,7 +538,7 @@ export function createKeyboardHandlers( clientY: number, modifiers: ScanModifierState = {}, ): void { - if (typeof PointerEvent !== 'undefined') { + if (typeof PointerEvent === 'function') { const pointerEventInit = { bubbles: true, cancelable: true, @@ -531,23 +561,25 @@ export function createKeyboardHandlers( target.dispatchEvent(new PointerEvent('pointerup', pointerEventInit)); } - const mouseEventInit = { - bubbles: true, - cancelable: true, - composed: true, - clientX, - clientY, - button: 0, - buttons: 0, - shiftKey: modifiers.shiftKey ?? false, - ctrlKey: modifiers.ctrlKey ?? false, - altKey: modifiers.altKey ?? false, - metaKey: modifiers.metaKey ?? false, - } satisfies MouseEventInit; - target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit)); - target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 })); - target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit)); - target.dispatchEvent(new MouseEvent('click', mouseEventInit)); + if (typeof MouseEvent === 'function') { + const mouseEventInit = { + bubbles: true, + cancelable: true, + composed: true, + clientX, + clientY, + button: 0, + buttons: 0, + shiftKey: modifiers.shiftKey ?? false, + ctrlKey: modifiers.ctrlKey ?? false, + altKey: modifiers.altKey ?? false, + metaKey: modifiers.metaKey ?? false, + } satisfies MouseEventInit; + target.dispatchEvent(new MouseEvent('mousemove', mouseEventInit)); + target.dispatchEvent(new MouseEvent('mousedown', { ...mouseEventInit, buttons: 1 })); + target.dispatchEvent(new MouseEvent('mouseup', mouseEventInit)); + target.dispatchEvent(new MouseEvent('click', mouseEventInit)); + } } function emitLookupScanFallback(target: Element, clientX: number, clientY: number): void { @@ -820,7 +852,7 @@ export function createKeyboardHandlers( if (modifierOnlyCodes.has(e.code)) return false; const keyString = keyEventToString(e); - if (ctx.state.keybindingsMap.has(keyString)) { + if (ctx.state.sessionBindingMap.has(keyString)) { return false; } @@ -846,7 +878,7 @@ export function createKeyboardHandlers( fallbackUnavailable: boolean; } { const firstChoice = 'KeyH'; - if (!ctx.state.keybindingsMap.has('KeyH')) { + if (!ctx.state.sessionBindingMap.has('KeyH')) { return { bindingKey: firstChoice, fallbackUsed: false, @@ -854,18 +886,18 @@ export function createKeyboardHandlers( }; } - if (ctx.state.keybindingsMap.has('KeyK')) { + if (!ctx.state.sessionBindingMap.has('KeyK')) { return { bindingKey: 'KeyK', fallbackUsed: true, - fallbackUnavailable: true, + fallbackUnavailable: false, }; } return { bindingKey: 'KeyK', fallbackUsed: true, - fallbackUnavailable: false, + fallbackUnavailable: true, }; } @@ -890,16 +922,14 @@ export function createKeyboardHandlers( } async function setupMpvInputForwarding(): Promise { - const [keybindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([ - window.electronAPI.getKeybindings(), + const [sessionBindings, shortcuts, statsToggleKey, markWatchedKey] = await Promise.all([ + window.electronAPI.getSessionBindings(), window.electronAPI.getConfiguredShortcuts(), window.electronAPI.getStatsToggleKey(), window.electronAPI.getMarkWatchedKey(), ]); - updateKeybindings(keybindings); - updateConfiguredShortcuts(shortcuts); - ctx.state.statsToggleKey = statsToggleKey; - ctx.state.markWatchedKey = markWatchedKey; + updateSessionBindings(sessionBindings); + updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey); syncKeyboardTokenSelection(); const subtitleMutationObserver = new MutationObserver(() => { @@ -1006,6 +1036,14 @@ export function createKeyboardHandlers( return; } + if (isTextEntryTarget(e.target)) { + return; + } + + if (handlePendingNumericSelection(e)) { + return; + } + if (isStatsOverlayToggle(e)) { e.preventDefault(); window.electronAPI.toggleStatsOverlay(); @@ -1024,10 +1062,7 @@ export function createKeyboardHandlers( return; } - if ( - (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) && - !isControllerModalShortcut(e) - ) { + if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) { if (handleYomitanPopupKeybind(e)) { e.preventDefault(); return; @@ -1084,30 +1119,11 @@ export function createKeyboardHandlers( return; } - if (isControllerModalShortcut(e)) { - e.preventDefault(); - if (e.shiftKey) { - options.openControllerDebugModal(); - } else { - options.openControllerSelectModal(); - } - return; - } - const keyString = keyEventToString(e); - const linuxOverlayCommand = ctx.platform.isLinuxPlatform - ? linuxOverlayShortcutCommands.get(keyString) - : undefined; - if (linuxOverlayCommand) { + const binding = ctx.state.sessionBindingMap.get(keyString); + if (binding) { e.preventDefault(); - dispatchConfiguredMpvCommand(linuxOverlayCommand); - return; - } - const command = ctx.state.keybindingsMap.get(keyString); - - if (command) { - e.preventDefault(); - dispatchConfiguredMpvCommand(command); + dispatchSessionBinding(binding); } }); @@ -1125,19 +1141,12 @@ export function createKeyboardHandlers( }); } - function updateKeybindings(keybindings: Keybinding[]): void { - ctx.state.keybindingsMap = new Map(); - for (const binding of keybindings) { - if (binding.command) { - ctx.state.keybindingsMap.set(binding.key, binding.command); - } - } - } - return { + beginSessionNumericSelection, + getSessionHelpOpeningInfo: resolveSessionHelpChordBinding, setupMpvInputForwarding, refreshConfiguredShortcuts, - updateKeybindings, + updateSessionBindings, syncKeyboardTokenSelection, handleSubtitleContentUpdated, handleKeyboardModeToggleRequested, diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index 6018c885..03f89aea 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -3,7 +3,12 @@ import test from 'node:test'; import type { SubtitleSidebarConfig } from '../../types'; import { createMouseHandlers } from './mouse.js'; -import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js'; +import { + YOMITAN_POPUP_HIDDEN_EVENT, + YOMITAN_POPUP_HOST_SELECTOR, + YOMITAN_POPUP_SHOWN_EVENT, + YOMITAN_POPUP_VISIBLE_HOST_SELECTOR, +} from '../yomitan-popup.js'; function createClassList() { const classes = new Set(); @@ -78,11 +83,13 @@ function createMouseTestContext() { }, platform: { shouldToggleMouseIgnore: false, + isLinuxPlatform: false, isMacOSPlatform: false, }, state: { isOverSubtitle: false, isOverSubtitleSidebar: false, + yomitanPopupVisible: false, subtitleSidebarModalOpen: false, subtitleSidebarConfig: null as SubtitleSidebarConfig | null, isDragging: false, @@ -712,6 +719,257 @@ test('popup open pauses and popup close resumes when yomitan popup auto-pause is } }); +test('nested popup close reasserts interactive state and focus when another popup remains visible on Windows', async () => { + const ctx = createMouseTestContext(); + const previousWindow = (globalThis as { window?: unknown }).window; + const previousDocument = (globalThis as { document?: unknown }).document; + const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver; + const previousNode = (globalThis as { Node?: unknown }).Node; + const windowListeners = new Map void>>(); + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + let focusMainWindowCalls = 0; + let windowFocusCalls = 0; + let overlayFocusCalls = 0; + + ctx.platform.shouldToggleMouseIgnore = true; + (ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => { + overlayFocusCalls += 1; + }; + + const visiblePopupHost = { + tagName: 'DIV', + getAttribute: (name: string) => + name === 'data-subminer-yomitan-popup-visible' ? 'true' : null, + }; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: (type: string, listener: () => void) => { + const bucket = windowListeners.get(type) ?? []; + bucket.push(listener); + windowListeners.set(type, bucket); + }, + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + focusMainWindow: () => { + focusMainWindowCalls += 1; + }, + }, + focus: () => { + windowFocusCalls += 1; + }, + getComputedStyle: () => ({ + visibility: 'visible', + display: 'block', + opacity: '1', + }), + innerHeight: 1000, + getSelection: () => null, + setTimeout, + clearTimeout, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + querySelector: () => null, + querySelectorAll: (selector: string) => { + if ( + selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || + selector === YOMITAN_POPUP_HOST_SELECTOR + ) { + return [visiblePopupHost]; + } + return []; + }, + body: {}, + elementFromPoint: () => null, + addEventListener: () => {}, + }, + }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: class { + observe() {} + }, + }); + Object.defineProperty(globalThis, 'Node', { + configurable: true, + value: { + ELEMENT_NODE: 1, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupYomitanObserver(); + ignoreCalls.length = 0; + + for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) { + listener(); + } + + assert.equal(ctx.state.yomitanPopupVisible, true); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); + assert.equal(focusMainWindowCalls, 1); + assert.equal(windowFocusCalls, 1); + assert.equal(overlayFocusCalls, 1); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: previousMutationObserver, + }); + Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); + } +}); + +test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => { + const ctx = createMouseTestContext(); + const previousWindow = (globalThis as { window?: unknown }).window; + const previousDocument = (globalThis as { document?: unknown }).document; + const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver; + const previousNode = (globalThis as { Node?: unknown }).Node; + const windowListeners = new Map void>>(); + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + let focusMainWindowCalls = 0; + let windowFocusCalls = 0; + let overlayFocusCalls = 0; + + ctx.platform.shouldToggleMouseIgnore = true; + (ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => { + overlayFocusCalls += 1; + }; + + const visiblePopupHost = { + tagName: 'DIV', + getAttribute: (name: string) => + name === 'data-subminer-yomitan-popup-visible' ? 'true' : null, + }; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: (type: string, listener: () => void) => { + const bucket = windowListeners.get(type) ?? []; + bucket.push(listener); + windowListeners.set(type, bucket); + }, + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + focusMainWindow: () => { + focusMainWindowCalls += 1; + }, + }, + focus: () => { + windowFocusCalls += 1; + }, + getComputedStyle: () => ({ + visibility: 'visible', + display: 'block', + opacity: '1', + }), + innerHeight: 1000, + getSelection: () => null, + setTimeout, + clearTimeout, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + visibilityState: 'visible', + querySelector: () => null, + querySelectorAll: (selector: string) => { + if ( + selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR || + selector === YOMITAN_POPUP_HOST_SELECTOR + ) { + return [visiblePopupHost]; + } + return []; + }, + body: {}, + elementFromPoint: () => null, + addEventListener: () => {}, + }, + }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: class { + observe() {} + }, + }); + Object.defineProperty(globalThis, 'Node', { + configurable: true, + value: { + ELEMENT_NODE: 1, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupYomitanObserver(); + assert.equal(ctx.state.yomitanPopupVisible, true); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); + ignoreCalls.length = 0; + + for (const listener of windowListeners.get('blur') ?? []) { + listener(); + } + await Promise.resolve(); + + assert.equal(ctx.state.yomitanPopupVisible, true); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); + assert.equal(focusMainWindowCalls, 1); + assert.equal(windowFocusCalls, 1); + assert.equal(overlayFocusCalls, 1); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); + Object.defineProperty(globalThis, 'MutationObserver', { + configurable: true, + value: previousMutationObserver, + }); + Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode }); + } +}); + test('restorePointerInteractionState re-enables subtitle hover when pointer is already over subtitles', () => { const ctx = createMouseTestContext(); const originalWindow = globalThis.window; @@ -783,6 +1041,361 @@ test('restorePointerInteractionState re-enables subtitle hover when pointer is a } }); +test('visibility recovery re-enables subtitle hover without needing a fresh pointer move', () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const documentListeners = new Map void>>(); + let visibilityState: 'hidden' | 'visible' = 'visible'; + ctx.platform.shouldToggleMouseIgnore = true; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + getComputedStyle: () => ({ + visibility: 'hidden', + display: 'none', + opacity: '0', + }), + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const bucket = documentListeners.get(type) ?? []; + bucket.push(listener); + documentListeners.set(type, bucket); + }, + get visibilityState() { + return visibilityState; + }, + elementFromPoint: () => ctx.dom.subtitleContainer, + querySelectorAll: () => [], + body: {}, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupPointerTracking(); + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 120, clientY: 240 }); + } + + ctx.state.isOverSubtitle = false; + ctx.dom.overlay.classList.remove('interactive'); + ignoreCalls.length = 0; + visibilityState = 'hidden'; + visibilityState = 'visible'; + + for (const listener of documentListeners.get('visibilitychange') ?? []) { + listener({}); + } + + assert.equal(ctx.state.isOverSubtitle, true); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); + +test('visibility recovery ignores synthetic subtitle enter until the pointer moves again', async () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const mpvCommands: Array<(string | number)[]> = []; + const documentListeners = new Map void>>(); + let hoveredElement: unknown = ctx.dom.subtitleContainer; + let visibilityState: 'hidden' | 'visible' = 'visible'; + let subtitleHoverAutoPauseEnabled = false; + ctx.platform.shouldToggleMouseIgnore = true; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + getComputedStyle: () => ({ + visibility: 'hidden', + display: 'none', + opacity: '0', + }), + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const bucket = documentListeners.get(type) ?? []; + bucket.push(listener); + documentListeners.set(type, bucket); + }, + get visibilityState() { + return visibilityState; + }, + elementFromPoint: () => hoveredElement, + querySelectorAll: () => [], + body: {}, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => subtitleHoverAutoPauseEnabled, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + handlers.setupPointerTracking(); + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 120, clientY: 240 }); + } + await waitForNextTick(); + + ignoreCalls.length = 0; + visibilityState = 'hidden'; + visibilityState = 'visible'; + subtitleHoverAutoPauseEnabled = true; + for (const listener of documentListeners.get('visibilitychange') ?? []) { + listener({}); + } + + await handlers.handlePrimaryMouseEnter(); + assert.deepEqual(mpvCommands, []); + + hoveredElement = null; + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 32, clientY: 48 }); + } + + hoveredElement = ctx.dom.subtitleContainer; + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 120, clientY: 240 }); + } + await waitForNextTick(); + + assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); + +test('window resize ignores synthetic subtitle enter until the pointer moves again', async () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const mpvCommands: Array<(string | number)[]> = []; + const windowListeners = new Map void>>(); + const documentListeners = new Map void>>(); + let hoveredElement: unknown = ctx.dom.subtitleContainer; + let subtitleHoverAutoPauseEnabled = false; + ctx.platform.shouldToggleMouseIgnore = true; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: () => {}, + }, + addEventListener: (type: string, listener: () => void) => { + const bucket = windowListeners.get(type) ?? []; + bucket.push(listener); + windowListeners.set(type, bucket); + }, + getComputedStyle: () => ({ + visibility: 'hidden', + display: 'none', + opacity: '0', + }), + focus: () => {}, + innerHeight: 1000, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const bucket = documentListeners.get(type) ?? []; + bucket.push(listener); + documentListeners.set(type, bucket); + }, + elementFromPoint: () => hoveredElement, + querySelectorAll: () => [], + body: {}, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => subtitleHoverAutoPauseEnabled, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: (command) => { + mpvCommands.push(command); + }, + }); + + handlers.setupPointerTracking(); + handlers.setupResizeHandler(); + + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 120, clientY: 240 }); + } + await waitForNextTick(); + + subtitleHoverAutoPauseEnabled = true; + for (const listener of windowListeners.get('resize') ?? []) { + listener(); + } + + await handlers.handlePrimaryMouseEnter(); + assert.deepEqual(mpvCommands, []); + + hoveredElement = null; + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 32, clientY: 48 }); + } + + hoveredElement = ctx.dom.subtitleContainer; + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 120, clientY: 240 }); + } + await waitForNextTick(); + + assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); + +test('visibility recovery keeps overlay click-through when pointer is not over subtitles', () => { + const ctx = createMouseTestContext(); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const documentListeners = new Map void>>(); + let hoveredElement: unknown = null; + let visibilityState: 'hidden' | 'visible' = 'visible'; + ctx.platform.shouldToggleMouseIgnore = true; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + getComputedStyle: () => ({ + visibility: 'hidden', + display: 'none', + opacity: '0', + }), + focus: () => {}, + }, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + addEventListener: (type: string, listener: (event: unknown) => void) => { + const bucket = documentListeners.get(type) ?? []; + bucket.push(listener); + documentListeners.set(type, bucket); + }, + get visibilityState() { + return visibilityState; + }, + elementFromPoint: () => hoveredElement, + querySelectorAll: () => [], + body: {}, + }, + }); + + try { + const handlers = createMouseHandlers(ctx as never, { + modalStateReader: { + isAnySettingsModalOpen: () => false, + isAnyModalOpen: () => false, + }, + applyYPercent: () => {}, + getCurrentYPercent: () => 10, + persistSubtitlePositionPatch: () => {}, + getSubtitleHoverAutoPauseEnabled: () => false, + getYomitanPopupAutoPauseEnabled: () => false, + getPlaybackPaused: async () => false, + sendMpvCommand: () => {}, + }); + + handlers.setupPointerTracking(); + for (const listener of documentListeners.get('mousemove') ?? []) { + listener({ clientX: 320, clientY: 180 }); + } + + ctx.dom.overlay.classList.add('interactive'); + ignoreCalls.length = 0; + visibilityState = 'hidden'; + visibilityState = 'visible'; + + for (const listener of documentListeners.get('visibilitychange') ?? []) { + listener({}); + } + + assert.equal(ctx.state.isOverSubtitle, false); + assert.equal(ctx.dom.overlay.classList.contains('interactive'), false); + assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]); + } finally { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); + } +}); + test('pointer tracking enables overlay interaction as soon as the cursor reaches subtitles', () => { const ctx = createMouseTestContext(); const originalWindow = globalThis.window; @@ -916,10 +1529,8 @@ test('pointer tracking restores click-through after the cursor leaves subtitles' assert.equal(ctx.state.isOverSubtitle, false); assert.equal(ctx.dom.overlay.classList.contains('interactive'), false); - assert.deepEqual(ignoreCalls, [ - { ignore: false, forward: undefined }, - { ignore: true, forward: true }, - ]); + assert.equal(ignoreCalls[0]?.ignore, false); + assert.deepEqual(ignoreCalls.at(-1), { ignore: true, forward: true }); } finally { Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument }); diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index 5c7e6e63..5180eb73 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -2,11 +2,16 @@ import type { ModalStateReader, RendererContext } from '../context'; import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js'; import { YOMITAN_POPUP_HIDDEN_EVENT, + YOMITAN_POPUP_MOUSE_ENTER_EVENT, + YOMITAN_POPUP_MOUSE_LEAVE_EVENT, YOMITAN_POPUP_SHOWN_EVENT, isYomitanPopupVisible, isYomitanPopupIframe, } from '../yomitan-popup.js'; +const VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE = 'visibility-recovery'; +const WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE = 'window-resize'; + export function createMouseHandlers( ctx: RendererContext, options: { @@ -33,6 +38,61 @@ export function createMouseHandlers( let pausedByYomitanPopup = false; let lastPointerPosition: { clientX: number; clientY: number } | null = null; let pendingPointerResync = false; + let suppressDirectHoverEnterSource: string | null = null; + + function getPopupVisibilityFromDom(): boolean { + return typeof document !== 'undefined' && isYomitanPopupVisible(document); + } + + function syncPopupVisibilityState(assumeVisible = false): boolean { + const popupVisible = assumeVisible || getPopupVisibilityFromDom(); + yomitanPopupVisible = popupVisible; + ctx.state.yomitanPopupVisible = popupVisible; + return popupVisible; + } + + function reclaimOverlayWindowFocusForPopup(): void { + if (!ctx.platform.shouldToggleMouseIgnore) { + return; + } + if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) { + return; + } + + if (typeof window.electronAPI.focusMainWindow === 'function') { + void window.electronAPI.focusMainWindow(); + } + window.focus(); + if (typeof ctx.dom.overlay.focus === 'function') { + ctx.dom.overlay.focus({ preventScroll: true }); + } + } + + function sustainPopupInteraction(): void { + syncPopupVisibilityState(true); + syncOverlayMouseIgnoreState(ctx); + } + + function reconcilePopupInteraction(args: { + assumeVisible?: boolean; + reclaimFocus?: boolean; + allowPause?: boolean; + } = {}): boolean { + const popupVisible = syncPopupVisibilityState(args.assumeVisible === true); + if (!popupVisible) { + syncOverlayMouseIgnoreState(ctx); + return false; + } + + syncOverlayMouseIgnoreState(ctx); + if (args.reclaimFocus === true) { + reclaimOverlayWindowFocusForPopup(); + } + if (args.allowPause === true) { + void maybePauseForYomitanPopup(); + } + return true; + } function isElementWithinContainer(element: Element | null, container: HTMLElement): boolean { if (!element) { @@ -86,6 +146,7 @@ export function createMouseHandlers( return; } + suppressDirectHoverEnterSource = null; const wasOverSubtitle = ctx.state.isOverSubtitle; const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains( 'secondary-sub-hover-active', @@ -93,7 +154,7 @@ export function createMouseHandlers( const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY); if (!wasOverSubtitle && hoverState.isOverSubtitle) { - void handleMouseEnter(undefined, hoverState.overSecondarySubtitle); + void handleMouseEnter(undefined, hoverState.overSecondarySubtitle, 'tracked-pointer'); return; } @@ -110,9 +171,13 @@ export function createMouseHandlers( } } - function restorePointerInteractionState(): void { + function resyncPointerInteractionState(options: { + allowInteractiveFallback: boolean; + suppressDirectHoverEnterSource?: string | null; + }): void { const pointerPosition = lastPointerPosition; pendingPointerResync = false; + suppressDirectHoverEnterSource = options.suppressDirectHoverEnterSource ?? null; if (pointerPosition) { syncHoverStateFromPoint(pointerPosition.clientX, pointerPosition.clientY); } else { @@ -121,7 +186,11 @@ export function createMouseHandlers( } syncOverlayMouseIgnoreState(ctx); - if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isOverSubtitle) { + if ( + !options.allowInteractiveFallback || + !ctx.platform.shouldToggleMouseIgnore || + ctx.state.isOverSubtitle + ) { return; } @@ -130,6 +199,10 @@ export function createMouseHandlers( window.electronAPI.setIgnoreMouseEvents(false); } + function restorePointerInteractionState(): void { + resyncPointerInteractionState({ allowInteractiveFallback: true }); + } + function maybeResyncPointerHoverState(event: MouseEvent | PointerEvent): void { if (!pendingPointerResync) { return; @@ -205,18 +278,14 @@ export function createMouseHandlers( } function enablePopupInteraction(): void { - yomitanPopupVisible = true; - ctx.state.yomitanPopupVisible = true; - syncOverlayMouseIgnoreState(ctx); + sustainPopupInteraction(); if (ctx.platform.isMacOSPlatform) { window.focus(); } } function disablePopupInteractionIfIdle(): void { - if (typeof document !== 'undefined' && isYomitanPopupVisible(document)) { - yomitanPopupVisible = true; - ctx.state.yomitanPopupVisible = true; + if (reconcilePopupInteraction({ reclaimFocus: true })) { return; } @@ -228,7 +297,15 @@ export function createMouseHandlers( syncOverlayMouseIgnoreState(ctx); } - async function handleMouseEnter(_event?: MouseEvent, showSecondaryHover = false): Promise { + async function handleMouseEnter( + _event?: MouseEvent, + showSecondaryHover = false, + source: 'direct' | 'tracked-pointer' = 'direct', + ): Promise { + if (source === 'direct' && suppressDirectHoverEnterSource !== null) { + return; + } + ctx.state.isOverSubtitle = true; if (showSecondaryHover) { ctx.dom.secondarySubContainer.classList.add('secondary-sub-hover-active'); @@ -326,6 +403,10 @@ export function createMouseHandlers( function setupResizeHandler(): void { window.addEventListener('resize', () => { options.applyYPercent(options.getCurrentYPercent()); + resyncPointerInteractionState({ + allowInteractiveFallback: false, + suppressDirectHoverEnterSource: WINDOW_RESIZE_HOVER_SUPPRESSION_SOURCE, + }); }); } @@ -340,6 +421,15 @@ export function createMouseHandlers( syncHoverStateFromTrackedPointer(event); maybeResyncPointerHoverState(event); }); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') { + return; + } + resyncPointerInteractionState({ + allowInteractiveFallback: false, + suppressDirectHoverEnterSource: VISIBILITY_RECOVERY_HOVER_SUPPRESSION_SOURCE, + }); + }); } function setupSelectionObserver(): void { @@ -356,19 +446,37 @@ export function createMouseHandlers( } function setupYomitanObserver(): void { - yomitanPopupVisible = isYomitanPopupVisible(document); - ctx.state.yomitanPopupVisible = yomitanPopupVisible; - void maybePauseForYomitanPopup(); + reconcilePopupInteraction({ allowPause: true }); window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => { - enablePopupInteraction(); - void maybePauseForYomitanPopup(); + reconcilePopupInteraction({ assumeVisible: true, allowPause: true }); }); window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => { disablePopupInteractionIfIdle(); }); + window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => { + reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true }); + }); + + window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => { + reconcilePopupInteraction({ assumeVisible: true }); + }); + + window.addEventListener('focus', () => { + reconcilePopupInteraction(); + }); + + window.addEventListener('blur', () => { + queueMicrotask(() => { + if (typeof document === 'undefined' || document.visibilityState !== 'visible') { + return; + } + reconcilePopupInteraction({ reclaimFocus: true }); + }); + }); + const observer = new MutationObserver((mutations: MutationRecord[]) => { for (const mutation of mutations) { mutation.addedNodes.forEach((node) => { diff --git a/src/renderer/modals/runtime-options.test.ts b/src/renderer/modals/runtime-options.test.ts new file mode 100644 index 00000000..a1d3a5de --- /dev/null +++ b/src/renderer/modals/runtime-options.test.ts @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { ElectronAPI, RuntimeOptionState } from '../../types'; +import { createRendererState } from '../state.js'; +import { createRuntimeOptionsModal } from './runtime-options.js'; + +function createClassList(initialTokens: string[] = []) { + const tokens = new Set(initialTokens); + return { + add: (...entries: string[]) => { + for (const entry of entries) tokens.add(entry); + }, + remove: (...entries: string[]) => { + for (const entry of entries) tokens.delete(entry); + }, + toggle: (entry: string, force?: boolean) => { + if (force === undefined) { + if (tokens.has(entry)) { + tokens.delete(entry); + return false; + } + tokens.add(entry); + return true; + } + if (force) tokens.add(entry); + else tokens.delete(entry); + return force; + }, + contains: (entry: string) => tokens.has(entry), + }; +} + +function createElementStub() { + return { + className: '', + textContent: '', + title: '', + classList: createClassList(), + appendChild: () => {}, + addEventListener: () => {}, + }; +} + +function createRuntimeOptionsListStub() { + return { + innerHTML: '', + appendChild: () => {}, + querySelector: () => null, + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +} + +function flushAsyncWork(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +function withRuntimeOptionsModal( + getRuntimeOptions: () => Promise, + run: (input: { + modal: ReturnType; + state: ReturnType; + overlayClassList: ReturnType; + modalClassList: ReturnType; + statusNode: { + textContent: string; + classList: ReturnType; + }; + syncCalls: string[]; + }) => Promise | void, +): Promise { + const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown }; + const previousWindow = globals.window; + const previousDocument = globals.document; + + const statusNode = { + textContent: '', + classList: createClassList(), + }; + const overlayClassList = createClassList(); + const modalClassList = createClassList(['hidden']); + const syncCalls: string[] = []; + const state = createRendererState(); + + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: { + electronAPI: { + getRuntimeOptions, + setRuntimeOptionValue: async () => ({ ok: true }), + notifyOverlayModalClosed: () => {}, + } satisfies Pick< + ElectronAPI, + 'getRuntimeOptions' | 'setRuntimeOptionValue' | 'notifyOverlayModalClosed' + >, + }, + }); + + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + createElement: () => createElementStub(), + }, + }); + + const modal = createRuntimeOptionsModal( + { + dom: { + overlay: { classList: overlayClassList }, + runtimeOptionsModal: { + classList: modalClassList, + setAttribute: () => {}, + }, + runtimeOptionsClose: { + addEventListener: () => {}, + }, + runtimeOptionsList: createRuntimeOptionsListStub(), + runtimeOptionsStatus: statusNode, + }, + state, + } as never, + { + modalStateReader: { isAnyModalOpen: () => false }, + syncSettingsModalSubtitleSuppression: () => { + syncCalls.push('sync'); + }, + }, + ); + + return Promise.resolve() + .then(() => + run({ + modal, + state, + overlayClassList, + modalClassList, + statusNode, + syncCalls, + }), + ) + .finally(() => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: previousWindow, + }); + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: previousDocument, + }); + }); +} + +test('openRuntimeOptionsModal shows loading shell before runtime options resolve', async () => { + const deferred = createDeferred(); + + await withRuntimeOptionsModal(() => deferred.promise, async (input) => { + input.modal.openRuntimeOptionsModal(); + + assert.equal(input.state.runtimeOptionsModalOpen, true); + assert.equal(input.overlayClassList.contains('interactive'), true); + assert.equal(input.modalClassList.contains('hidden'), false); + assert.equal(input.statusNode.textContent, 'Loading runtime options...'); + assert.deepEqual(input.syncCalls, ['sync']); + + deferred.resolve([ + { + id: 'anki.autoUpdateNewCards', + label: 'Auto-update new cards', + scope: 'ankiConnect', + valueType: 'boolean', + value: true, + allowedValues: [true, false], + requiresRestart: false, + }, + ]); + await flushAsyncWork(); + + assert.equal( + input.statusNode.textContent, + 'Use arrow keys. Click value to cycle. Enter or double-click to apply.', + ); + assert.equal(input.statusNode.classList.contains('error'), false); + }); +}); + +test('openRuntimeOptionsModal keeps modal visible when loading fails', async () => { + const deferred = createDeferred(); + + await withRuntimeOptionsModal(() => deferred.promise, async (input) => { + input.modal.openRuntimeOptionsModal(); + deferred.reject(new Error('boom')); + await flushAsyncWork(); + + assert.equal(input.state.runtimeOptionsModalOpen, true); + assert.equal(input.overlayClassList.contains('interactive'), true); + assert.equal(input.modalClassList.contains('hidden'), false); + assert.equal(input.statusNode.textContent, 'Failed to load runtime options'); + assert.equal(input.statusNode.classList.contains('error'), true); + }); +}); diff --git a/src/renderer/modals/runtime-options.ts b/src/renderer/modals/runtime-options.ts index 8b9cfdbd..ed7f9c83 100644 --- a/src/renderer/modals/runtime-options.ts +++ b/src/renderer/modals/runtime-options.ts @@ -22,6 +22,9 @@ export function createRuntimeOptionsModal( syncSettingsModalSubtitleSuppression: () => void; }, ) { + const DEFAULT_STATUS_MESSAGE = + 'Use arrow keys. Click value to cycle. Enter or double-click to apply.'; + function formatRuntimeOptionValue(value: RuntimeOptionValue): string { if (typeof value === 'boolean') { return value ? 'On' : 'Off'; @@ -177,10 +180,13 @@ export function createRuntimeOptionsModal( } } - async function openRuntimeOptionsModal(): Promise { + async function refreshRuntimeOptions(): Promise { const optionsList = await window.electronAPI.getRuntimeOptions(); updateRuntimeOptions(optionsList); + setRuntimeOptionsStatus(DEFAULT_STATUS_MESSAGE); + } + function showRuntimeOptionsModalShell(): void { ctx.state.runtimeOptionsModalOpen = true; options.syncSettingsModalSubtitleSuppression(); @@ -188,9 +194,19 @@ export function createRuntimeOptionsModal( ctx.dom.runtimeOptionsModal.classList.remove('hidden'); ctx.dom.runtimeOptionsModal.setAttribute('aria-hidden', 'false'); - setRuntimeOptionsStatus( - 'Use arrow keys. Click value to cycle. Enter or double-click to apply.', - ); + setRuntimeOptionsStatus('Loading runtime options...'); + } + + function openRuntimeOptionsModal(): void { + if (!ctx.state.runtimeOptionsModalOpen) { + showRuntimeOptionsModalShell(); + } else { + setRuntimeOptionsStatus('Refreshing runtime options...'); + } + + void refreshRuntimeOptions().catch(() => { + setRuntimeOptionsStatus('Failed to load runtime options', true); + }); } function handleRuntimeOptionsKeydown(e: KeyboardEvent): boolean { diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts index 65f10f81..c1c46d0e 100644 --- a/src/renderer/modals/session-help.ts +++ b/src/renderer/modals/session-help.ts @@ -96,6 +96,10 @@ const OVERLAY_SHORTCUTS: Array<{ { key: 'markAudioCard', label: 'Mark audio card' }, { key: 'openRuntimeOptions', label: 'Open runtime options' }, { key: 'openJimaku', label: 'Open jimaku' }, + { key: 'openSessionHelp', label: 'Open session help' }, + { key: 'openControllerSelect', label: 'Open controller select' }, + { key: 'openControllerDebug', label: 'Open controller debug' }, + { key: 'toggleSubtitleSidebar', label: 'Toggle subtitle sidebar' }, { key: 'toggleVisibleOverlayGlobal', label: 'Show/hide visible overlay' }, ]; @@ -104,11 +108,12 @@ function buildOverlayShortcutSections(shortcuts: RuntimeShortcutConfig): Session for (const shortcut of OVERLAY_SHORTCUTS) { const keybind = shortcuts[shortcut.key]; - if (typeof keybind !== 'string') continue; - if (keybind.trim().length === 0) continue; rows.push({ - shortcut: formatKeybinding(keybind), + shortcut: + typeof keybind === 'string' && keybind.trim().length > 0 + ? formatKeybinding(keybind) + : 'Unbound', action: shortcut.label, }); } @@ -586,11 +591,25 @@ export function createSessionHelpModal( } } - async function openSessionHelpModal(opening: SessionHelpBindingInfo): Promise { + function openSessionHelpModal(opening: SessionHelpBindingInfo): void { openBinding = opening; priorFocus = document.activeElement; - const dataLoaded = await render(); + ctx.state.sessionHelpModalOpen = true; + helpSections = []; + helpFilterValue = ''; + options.syncSettingsModalSubtitleSuppression(); + ctx.dom.overlay.classList.add('interactive'); + ctx.dom.sessionHelpModal.classList.remove('hidden'); + ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'false'); + ctx.dom.sessionHelpModal.setAttribute('tabindex', '-1'); + ctx.dom.sessionHelpFilter.value = ''; + ctx.state.sessionHelpSelectedIndex = 0; + ctx.dom.sessionHelpContent.innerHTML = ''; + ctx.dom.sessionHelpContent.classList.remove('session-help-content-no-results'); + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(false); + } ctx.dom.sessionHelpShortcut.textContent = `Session help opened with ${formatBindingHint(openBinding)}`; if (openBinding.fallbackUnavailable) { @@ -601,27 +620,7 @@ export function createSessionHelpModal( } else { ctx.dom.sessionHelpWarning.textContent = ''; } - if (dataLoaded) { - ctx.dom.sessionHelpStatus.textContent = - 'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.'; - } else { - ctx.dom.sessionHelpStatus.textContent = - 'Session help data is unavailable right now. Press Esc to close.'; - ctx.dom.sessionHelpWarning.textContent = - 'Unable to load latest shortcut settings from the runtime.'; - } - - ctx.state.sessionHelpModalOpen = true; - options.syncSettingsModalSubtitleSuppression(); - ctx.dom.overlay.classList.add('interactive'); - ctx.dom.sessionHelpModal.classList.remove('hidden'); - ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'false'); - ctx.dom.sessionHelpModal.setAttribute('tabindex', '-1'); - ctx.dom.sessionHelpFilter.value = ''; - helpFilterValue = ''; - if (ctx.platform.shouldToggleMouseIgnore) { - window.electronAPI.setIgnoreMouseEvents(false); - } + ctx.dom.sessionHelpStatus.textContent = 'Loading session help data...'; if (focusGuard === null) { focusGuard = (event: FocusEvent) => { @@ -639,6 +638,19 @@ export function createSessionHelpModal( requestOverlayFocus(); window.focus(); enforceModalFocus(); + + void render().then((dataLoaded) => { + if (!ctx.state.sessionHelpModalOpen) return; + if (dataLoaded) { + ctx.dom.sessionHelpStatus.textContent = + 'Use Arrow keys, J/K/H/L, mouse, click, or / then type to filter. Esc closes.'; + } else { + ctx.dom.sessionHelpStatus.textContent = + 'Session help data is unavailable right now. Press Esc to close.'; + ctx.dom.sessionHelpWarning.textContent = + 'Unable to load latest shortcut settings from the runtime.'; + } + }); } function closeSessionHelpModal(): void { @@ -648,6 +660,7 @@ export function createSessionHelpModal( options.syncSettingsModalSubtitleSuppression(); ctx.dom.sessionHelpModal.classList.add('hidden'); ctx.dom.sessionHelpModal.setAttribute('aria-hidden', 'true'); + window.electronAPI.notifyOverlayModalClosed('session-help'); if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { ctx.dom.overlay.classList.remove('interactive'); } diff --git a/src/renderer/overlay-mouse-ignore.test.ts b/src/renderer/overlay-mouse-ignore.test.ts index e191a8a1..2c6e1617 100644 --- a/src/renderer/overlay-mouse-ignore.test.ts +++ b/src/renderer/overlay-mouse-ignore.test.ts @@ -15,6 +15,53 @@ function createClassList() { }; } +test('idle visible overlay starts click-through on platforms that toggle mouse ignore', () => { + const classList = createClassList(); + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const originalWindow = globalThis.window; + + Object.assign(globalThis, { + window: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + }, + }); + + try { + syncOverlayMouseIgnoreState({ + dom: { + overlay: { classList }, + }, + platform: { + shouldToggleMouseIgnore: true, + }, + state: { + isOverSubtitle: false, + isOverSubtitleSidebar: false, + yomitanPopupVisible: false, + controllerSelectModalOpen: false, + controllerDebugModalOpen: false, + jimakuModalOpen: false, + youtubePickerModalOpen: false, + kikuModalOpen: false, + runtimeOptionsModalOpen: false, + subsyncModalOpen: false, + sessionHelpModalOpen: false, + subtitleSidebarModalOpen: false, + subtitleSidebarConfig: null, + }, + } as never); + + assert.equal(classList.contains('interactive'), false); + assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]); + } finally { + Object.assign(globalThis, { window: originalWindow }); + } +}); + test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => { const classList = createClassList(); const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; @@ -61,3 +108,62 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact Object.assign(globalThis, { window: originalWindow }); } }); + +test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => { + const classList = createClassList(); + const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = []; + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + + Object.assign(globalThis, { + window: { + electronAPI: { + setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { + ignoreCalls.push({ ignore, forward: options?.forward }); + }, + }, + getComputedStyle: () => ({ + visibility: 'visible', + display: 'block', + opacity: '1', + }), + }, + document: { + querySelectorAll: (selector: string) => + selector === '[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]' + ? [{ getAttribute: () => 'true' }] + : [], + }, + }); + + try { + syncOverlayMouseIgnoreState({ + dom: { + overlay: { classList }, + }, + platform: { + shouldToggleMouseIgnore: true, + }, + state: { + isOverSubtitle: false, + isOverSubtitleSidebar: false, + yomitanPopupVisible: false, + controllerSelectModalOpen: false, + controllerDebugModalOpen: false, + jimakuModalOpen: false, + youtubePickerModalOpen: false, + kikuModalOpen: false, + runtimeOptionsModalOpen: false, + subsyncModalOpen: false, + sessionHelpModalOpen: false, + subtitleSidebarModalOpen: false, + subtitleSidebarConfig: null, + }, + } as never); + + assert.equal(classList.contains('interactive'), true); + assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); + } finally { + Object.assign(globalThis, { window: originalWindow, document: originalDocument }); + } +}); diff --git a/src/renderer/overlay-mouse-ignore.ts b/src/renderer/overlay-mouse-ignore.ts index 401277a8..1025a963 100644 --- a/src/renderer/overlay-mouse-ignore.ts +++ b/src/renderer/overlay-mouse-ignore.ts @@ -1,5 +1,6 @@ import type { RendererContext } from './context'; import type { RendererState } from './state'; +import { isYomitanPopupVisible } from './yomitan-popup.js'; function isBlockingOverlayModalOpen(state: RendererState): boolean { return Boolean( @@ -14,11 +15,21 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean { ); } +function isYomitanPopupInteractionActive(state: RendererState): boolean { + if (state.yomitanPopupVisible) { + return true; + } + if (typeof document === 'undefined') { + return false; + } + return isYomitanPopupVisible(document); +} + export function syncOverlayMouseIgnoreState(ctx: RendererContext): void { const shouldStayInteractive = ctx.state.isOverSubtitle || ctx.state.isOverSubtitleSidebar || - ctx.state.yomitanPopupVisible || + isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state); if (shouldStayInteractive) { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 1ab8fd5d..2c828db6 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -55,6 +55,8 @@ import { resolveRendererDom } from './utils/dom.js'; import { resolvePlatformInfo } from './utils/platform.js'; import { buildMpvLoadfileCommands, + buildMpvSubtitleAddCommands, + collectDroppedSubtitlePaths, collectDroppedVideoPaths, } from '../core/services/overlay-drop.js'; @@ -172,18 +174,16 @@ const keyboardHandlers = createKeyboardHandlers(ctx, { handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown, handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, openSessionHelpModal: sessionHelpModal.openSessionHelpModal, + openControllerSelectModal: () => { + controllerSelectModal.openControllerSelectModal(); + }, + openControllerDebugModal: () => { + controllerDebugModal.openControllerDebugModal(); + }, appendClipboardVideoToQueue: () => { void window.electronAPI.appendClipboardVideoToQueue(); }, getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(), - openControllerSelectModal: () => { - controllerSelectModal.openControllerSelectModal(); - window.electronAPI.notifyOverlayModalOpened('controller-select'); - }, - openControllerDebugModal: () => { - controllerDebugModal.openControllerDebugModal(); - window.electronAPI.notifyOverlayModalOpened('controller-debug'); - }, toggleSubtitleSidebarModal: () => { void subtitleSidebarModal.toggleSubtitleSidebarModal(); }, @@ -430,15 +430,27 @@ registerRendererGlobalErrorHandlers(window, recovery); function registerModalOpenHandlers(): void { window.electronAPI.onOpenRuntimeOptions(() => { - runGuardedAsync('runtime-options:open', async () => { - try { - await runtimeOptionsModal.openRuntimeOptionsModal(); - window.electronAPI.notifyOverlayModalOpened('runtime-options'); - } catch { - runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true); - window.electronAPI.notifyOverlayModalClosed('runtime-options'); - syncSettingsModalSubtitleSuppression(); - } + runGuarded('runtime-options:open', () => { + runtimeOptionsModal.openRuntimeOptionsModal(); + window.electronAPI.notifyOverlayModalOpened('runtime-options'); + }); + }); + window.electronAPI.onOpenSessionHelp(() => { + runGuarded('session-help:open', () => { + sessionHelpModal.openSessionHelpModal(keyboardHandlers.getSessionHelpOpeningInfo()); + window.electronAPI.notifyOverlayModalOpened('session-help'); + }); + }); + window.electronAPI.onOpenControllerSelect(() => { + runGuarded('controller-select:open', () => { + controllerSelectModal.openControllerSelectModal(); + window.electronAPI.notifyOverlayModalOpened('controller-select'); + }); + }); + window.electronAPI.onOpenControllerDebug(() => { + runGuarded('controller-debug:open', () => { + controllerDebugModal.openControllerDebugModal(); + window.electronAPI.notifyOverlayModalOpened('controller-debug'); }); }); window.electronAPI.onOpenJimaku(() => { @@ -496,6 +508,12 @@ function registerKeyboardCommandHandlers(): void { keyboardHandlers.handleLookupWindowToggleRequested(); }); }); + + window.electronAPI.onSubtitleSidebarToggle(() => { + runGuardedAsync('subtitle-sidebar:toggle', async () => { + await subtitleSidebarModal.toggleSubtitleSidebarModal(); + }); + }); } function runGuarded(action: string, fn: () => void): void { @@ -527,6 +545,12 @@ async function init(): Promise { if (ctx.platform.isMacOSPlatform) { document.body.classList.add('platform-macos'); } + if (ctx.platform.isWindowsPlatform) { + document.body.classList.add('platform-windows'); + } + if (ctx.platform.shouldToggleMouseIgnore) { + syncOverlayMouseIgnoreState(ctx); + } window.electronAPI.onSubtitle((data: SubtitleData) => { runGuarded('subtitle:update', () => { @@ -620,7 +644,7 @@ async function init(): Promise { }); window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => { runGuarded('config:hot-reload', () => { - keyboardHandlers.updateKeybindings(payload.keybindings); + keyboardHandlers.updateSessionBindings(payload.sessionBindings); void keyboardHandlers.refreshConfiguredShortcuts(); subtitleRenderer.applySubtitleStyle(payload.subtitleStyle); subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode); @@ -654,10 +678,6 @@ async function init(): Promise { ); measurementReporter.schedule(); - if (ctx.platform.shouldToggleMouseIgnore) { - syncOverlayMouseIgnoreState(ctx); - } - measurementReporter.emitNow(); } @@ -706,18 +726,28 @@ function setupDragDropToMpvQueue(): void { if (!event.dataTransfer) return; event.preventDefault(); - const droppedPaths = collectDroppedVideoPaths(event.dataTransfer); - const loadCommands = buildMpvLoadfileCommands(droppedPaths, event.shiftKey); + const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer); + const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer); + const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey); + const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths); for (const command of loadCommands) { window.electronAPI.sendMpvCommand(command); } + for (const command of subtitleCommands) { + window.electronAPI.sendMpvCommand(command); + } + const osdParts: string[] = []; if (loadCommands.length > 0) { const action = event.shiftKey ? 'Queued' : 'Loaded'; - window.electronAPI.sendMpvCommand([ - 'show-text', - `${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`, - '1500', - ]); + osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`); + } + if (subtitleCommands.length > 0) { + osdParts.push( + `Loaded ${subtitleCommands.length} subtitle file${subtitleCommands.length === 1 ? '' : 's'}`, + ); + } + if (osdParts.length > 0) { + window.electronAPI.sendMpvCommand(['show-text', osdParts.join(' | '), '1500']); } clearDropInteractive(); diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 40ddff59..c4902c81 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -1,4 +1,5 @@ import type { + CompiledSessionBinding, PlaylistBrowserSnapshot, ControllerButtonSnapshot, ControllerDeviceInfo, @@ -116,7 +117,9 @@ export type RendererState = { frequencyDictionaryBand4Color: string; frequencyDictionaryBand5Color: string; - keybindingsMap: Map; + sessionBindings: CompiledSessionBinding[]; + sessionBindingMap: Map; + sessionActionTimeoutMs: number; statsToggleKey: string; markWatchedKey: string; chordPending: boolean; @@ -219,7 +222,9 @@ export function createRendererState(): RendererState { frequencyDictionaryBand4Color: '#8bd5ca', frequencyDictionaryBand5Color: '#8aadf4', - keybindingsMap: new Map(), + sessionBindings: [], + sessionBindingMap: new Map(), + sessionActionTimeoutMs: 3000, statsToggleKey: 'Backquote', markWatchedKey: 'KeyW', chordPending: false, diff --git a/src/renderer/style.css b/src/renderer/style.css index 5ca4db41..641c2e7e 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -684,7 +684,8 @@ body.settings-modal-open #subtitleContainer { } body.settings-modal-open iframe.yomitan-popup, -body.settings-modal-open iframe[id^='yomitan-popup'] { +body.settings-modal-open iframe[id^='yomitan-popup'], +body.settings-modal-open [data-subminer-yomitan-popup-host='true'] { display: none !important; pointer-events: none !important; } @@ -1130,6 +1131,11 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover { justify-content: center; } +body.platform-windows #secondarySubContainer.secondary-sub-hover { + top: 40px; + padding-top: 0; +} + #secondarySubContainer.secondary-sub-hover #secondarySubRoot { background: transparent; backdrop-filter: none; @@ -1151,7 +1157,8 @@ body.subtitle-sidebar-embedded-open #secondarySubContainer.secondary-sub-hover { } iframe.yomitan-popup, -iframe[id^='yomitan-popup'] { +iframe[id^='yomitan-popup'], +[data-subminer-yomitan-popup-host='true'] { pointer-events: auto !important; z-index: 2147483647 !important; } diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index ef80f024..04fc6262 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -989,6 +989,13 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => { /transform:\s*translateX\(calc\(var\(--subtitle-sidebar-reserved-width\)\s*\*\s*-0\.5\)\);/, ); + const secondaryHoverWindowsBlock = extractClassBlock( + cssText, + 'body.platform-windows #secondarySubContainer.secondary-sub-hover', + ); + assert.match(secondaryHoverWindowsBlock, /top:\s*40px;/); + assert.match(secondaryHoverWindowsBlock, /padding-top:\s*0;/); + const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list'); assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/); diff --git a/src/renderer/utils/platform.ts b/src/renderer/utils/platform.ts index 7134c038..0169ed17 100644 --- a/src/renderer/utils/platform.ts +++ b/src/renderer/utils/platform.ts @@ -5,6 +5,7 @@ export type PlatformInfo = { isModalLayer: boolean; isLinuxPlatform: boolean; isMacOSPlatform: boolean; + isWindowsPlatform: boolean; shouldToggleMouseIgnore: boolean; }; @@ -24,12 +25,15 @@ export function resolvePlatformInfo(): PlatformInfo { const isLinuxPlatform = navigator.platform.toLowerCase().includes('linux'); const isMacOSPlatform = navigator.platform.toLowerCase().includes('mac') || /mac/i.test(navigator.userAgent); + const isWindowsPlatform = + navigator.platform.toLowerCase().includes('win') || /windows/i.test(navigator.userAgent); return { overlayLayer, isModalLayer, isLinuxPlatform, isMacOSPlatform, + isWindowsPlatform, shouldToggleMouseIgnore: !isLinuxPlatform && !isModalLayer, }; } diff --git a/src/renderer/yomitan-popup.test.ts b/src/renderer/yomitan-popup.test.ts index 239550c5..d38f7ccd 100644 --- a/src/renderer/yomitan-popup.test.ts +++ b/src/renderer/yomitan-popup.test.ts @@ -1,6 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { YOMITAN_LOOKUP_EVENT, registerYomitanLookupListener } from './yomitan-popup.js'; +import { + YOMITAN_LOOKUP_EVENT, + YOMITAN_POPUP_VISIBLE_HOST_SELECTOR, + isYomitanPopupVisible, + registerYomitanLookupListener, +} from './yomitan-popup.js'; test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event', () => { const target = new EventTarget(); @@ -16,3 +21,12 @@ test('registerYomitanLookupListener forwards the SubMiner Yomitan lookup event', assert.deepEqual(calls, ['lookup']); }); + +test('isYomitanPopupVisible falls back to querySelector when querySelectorAll is unavailable', () => { + const root = { + querySelector: (selector: string) => + selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ? ({} as Element) : null, + } as ParentNode; + + assert.equal(isYomitanPopupVisible(root), true); +}); diff --git a/src/renderer/yomitan-popup.ts b/src/renderer/yomitan-popup.ts index 28aa62f6..6aeb0c93 100644 --- a/src/renderer/yomitan-popup.ts +++ b/src/renderer/yomitan-popup.ts @@ -1,4 +1,8 @@ export const YOMITAN_POPUP_IFRAME_SELECTOR = 'iframe.yomitan-popup, iframe[id^="yomitan-popup"]'; +export const YOMITAN_POPUP_HOST_SELECTOR = '[data-subminer-yomitan-popup-host="true"]'; +export const YOMITAN_POPUP_VISIBLE_HOST_SELECTOR = + '[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'; +const YOMITAN_POPUP_VISIBLE_ATTRIBUTE = 'data-subminer-yomitan-popup-visible'; export const YOMITAN_POPUP_SHOWN_EVENT = 'yomitan-popup-shown'; export const YOMITAN_POPUP_HIDDEN_EVENT = 'yomitan-popup-hidden'; export const YOMITAN_POPUP_MOUSE_ENTER_EVENT = 'yomitan-popup-mouse-enter'; @@ -28,22 +32,64 @@ export function isYomitanPopupIframe(element: Element | null): boolean { return hasModernPopupClass || hasLegacyPopupId; } -export function hasYomitanPopupIframe(root: ParentNode = document): boolean { - return root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null; +export function hasYomitanPopupIframe(root: ParentNode | null | undefined = document): boolean { + return ( + typeof root?.querySelector === 'function' && + (root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null || + root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null) + ); } -export function isYomitanPopupVisible(root: ParentNode = document): boolean { - const popupIframes = root.querySelectorAll(YOMITAN_POPUP_IFRAME_SELECTOR); - for (const iframe of popupIframes) { - const rect = iframe.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) { - continue; - } - const styles = window.getComputedStyle(iframe); - if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') { - continue; - } +function isVisiblePopupElement(element: Element): boolean { + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return false; + } + + const styles = window.getComputedStyle(element); + if (styles.visibility === 'hidden' || styles.display === 'none' || styles.opacity === '0') { + return false; + } + + return true; +} + +function isMarkedVisiblePopupHost(element: Element): boolean { + return element.getAttribute(YOMITAN_POPUP_VISIBLE_ATTRIBUTE) === 'true'; +} + +function queryPopupElements( + root: ParentNode | null | undefined, + selector: string, +): T[] { + if (typeof root?.querySelectorAll === 'function') { + return Array.from(root.querySelectorAll(selector)); + } + if (typeof root?.querySelector === 'function') { + const first = root.querySelector(selector) as T | null; + return first ? [first] : []; + } + return []; +} + +export function isYomitanPopupVisible(root: ParentNode | null | undefined = document): boolean { + const visiblePopupHosts = queryPopupElements(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR); + if (visiblePopupHosts.length > 0) { return true; } + + const popupIframes = queryPopupElements(root, YOMITAN_POPUP_IFRAME_SELECTOR); + for (const iframe of popupIframes) { + if (isVisiblePopupElement(iframe)) { + return true; + } + } + + const popupHosts = queryPopupElements(root, YOMITAN_POPUP_HOST_SELECTOR); + for (const host of popupHosts) { + if (isMarkedVisiblePopupHost(host)) { + return true; + } + } return false; } diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 30615b4a..5b83d86c 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -11,6 +11,7 @@ export const OVERLAY_HOSTED_MODALS = [ 'controller-select', 'controller-debug', 'subtitle-sidebar', + 'session-help', ] as const; export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number]; @@ -37,6 +38,7 @@ export const IPC_CHANNELS = { overlayModalOpened: 'overlay:modal-opened', toggleStatsOverlay: 'stats:toggle-overlay', markActiveVideoWatched: 'immersion:mark-active-video-watched', + dispatchSessionAction: 'session-action:dispatch', }, request: { getVisibleOverlayVisibility: 'get-visible-overlay-visibility', @@ -49,6 +51,7 @@ export const IPC_CHANNELS = { getSubtitleStyle: 'get-subtitle-style', getMecabStatus: 'get-mecab-status', getKeybindings: 'get-keybindings', + getSessionBindings: 'get-session-bindings', getConfigShortcuts: 'get-config-shortcuts', getStatsToggleKey: 'get-stats-toggle-key', getMarkWatchedKey: 'get-mark-watched-key', @@ -109,6 +112,10 @@ export const IPC_CHANNELS = { playlistBrowserOpen: 'playlist-browser:open', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested', + sessionHelpOpen: 'session-help:open', + controllerSelectOpen: 'controller-select:open', + controllerDebugOpen: 'controller-debug:open', + subtitleSidebarToggle: 'subtitle-sidebar:toggle', configHotReload: 'config:hot-reload', }, } as const; diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index 1de11127..4fcb8c57 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -8,12 +8,41 @@ import type { import type { ControllerConfigUpdate, ControllerPreferenceUpdate, + SessionActionDispatchRequest, SubsyncManualRunRequest, } from '../../types/runtime'; import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-options'; +import type { SessionActionId, SessionActionPayload } from '../../types/session-bindings'; import type { SubtitlePosition } from '../../types/subtitle'; import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts'; +const SESSION_ACTION_IDS: SessionActionId[] = [ + 'toggleStatsOverlay', + 'toggleVisibleOverlay', + 'copySubtitle', + 'copySubtitleMultiple', + 'updateLastCardFromClipboard', + 'triggerFieldGrouping', + 'triggerSubsync', + 'mineSentence', + 'mineSentenceMultiple', + 'toggleSecondarySub', + 'markAudioCard', + 'toggleSubtitleSidebar', + 'openRuntimeOptions', + 'openSessionHelp', + 'openControllerSelect', + 'openControllerDebug', + 'openJimaku', + 'openYoutubePicker', + 'openPlaylistBrowser', + 'replayCurrentSubtitle', + 'playNextSubtitle', + 'shiftSubDelayPrevLine', + 'shiftSubDelayNextLine', + 'cycleRuntimeOption', +]; + const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [ 'anki.autoUpdateNewCards', 'subtitle.annotation.nPlusOne', @@ -35,6 +64,43 @@ function isInteger(value: unknown): value is number { return typeof value === 'number' && Number.isInteger(value); } +function isSessionActionId(value: unknown): value is SessionActionId { + return typeof value === 'string' && SESSION_ACTION_IDS.includes(value as SessionActionId); +} + +function parseSessionActionPayload( + actionId: SessionActionId, + value: unknown, +): SessionActionPayload | undefined | null { + if (actionId === 'copySubtitleMultiple' || actionId === 'mineSentenceMultiple') { + if (value === undefined) return undefined; + if (!isObject(value)) return null; + const keys = Object.keys(value); + if (keys.some((key) => key !== 'count')) return null; + if (value.count === undefined) return null; + if (!isInteger(value.count) || value.count < 1) return null; + return { count: value.count }; + } + + if (actionId === 'cycleRuntimeOption') { + if (!isObject(value)) return null; + const keys = Object.keys(value); + if (keys.some((key) => key !== 'runtimeOptionId' && key !== 'direction')) return null; + if (typeof value.runtimeOptionId !== 'string' || value.runtimeOptionId.trim().length === 0) { + return null; + } + if (value.direction !== 1 && value.direction !== -1) { + return null; + } + return { + runtimeOptionId: value.runtimeOptionId, + direction: value.direction, + }; + } + + return value === undefined ? undefined : null; +} + export function parseOverlayHostedModal(value: unknown): OverlayHostedModal | null { if (typeof value !== 'string') return null; return OVERLAY_HOSTED_MODALS.includes(value as OverlayHostedModal) @@ -182,6 +248,17 @@ export function parseRuntimeOptionValue(value: unknown): RuntimeOptionValue | nu : null; } +export function parseSessionActionDispatchRequest( + value: unknown, +): SessionActionDispatchRequest | null { + if (!isObject(value)) return null; + if (!isSessionActionId(value.actionId)) return null; + + const payload = parseSessionActionPayload(value.actionId, value.payload); + if (payload === null) return null; + return payload === undefined ? { actionId: value.actionId } : { actionId: value.actionId, payload }; +} + export function parseMpvCommand(value: unknown): Array | null { if (!Array.isArray(value)) return null; return value.every((entry) => typeof entry === 'string' || typeof entry === 'number') diff --git a/src/types.ts b/src/types.ts index 33e5adb1..4d066f56 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,4 +3,5 @@ export * from './types/config'; export * from './types/integrations'; export * from './types/runtime'; export * from './types/runtime-options'; +export * from './types/session-bindings'; export * from './types/subtitle'; diff --git a/src/types/config.ts b/src/types/config.ts index e6cc4208..f6e410c5 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -89,6 +89,10 @@ export interface ShortcutsConfig { markAudioCard?: string | null; openRuntimeOptions?: string | null; openJimaku?: string | null; + openSessionHelp?: string | null; + openControllerSelect?: string | null; + openControllerDebug?: string | null; + toggleSubtitleSidebar?: string | null; } export interface Config { diff --git a/src/types/runtime.ts b/src/types/runtime.ts index b9949f7a..fa8f60a6 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -5,6 +5,12 @@ import type { KikuMergePreviewResponse, } from './anki'; import type { ResolvedConfig, ShortcutsConfig } from './config'; +import type { + CompiledSessionBinding, + SessionActionId, + SessionActionPayload, + SessionBindingWarning, +} from './session-bindings'; import type { JimakuApiResponse, JimakuDownloadQuery, @@ -321,11 +327,18 @@ export interface ClipboardAppendResult { export interface ConfigHotReloadPayload { keybindings: Keybinding[]; + sessionBindings: CompiledSessionBinding[]; + sessionBindingWarnings: SessionBindingWarning[]; subtitleStyle: SubtitleStyleConfig | null; subtitleSidebar: Required; secondarySubMode: SecondarySubMode; } +export interface SessionActionDispatchRequest { + actionId: SessionActionId; + payload?: SessionActionPayload; +} + export type ResolvedControllerConfig = ResolvedConfig['controller']; export interface ElectronAPI { @@ -349,7 +362,9 @@ export interface ElectronAPI { setMecabEnabled: (enabled: boolean) => void; sendMpvCommand: (command: (string | number)[]) => void; getKeybindings: () => Promise; + getSessionBindings: () => Promise; getConfiguredShortcuts: () => Promise>; + dispatchSessionAction: (actionId: SessionActionId, payload?: SessionActionPayload) => Promise; getStatsToggleKey: () => Promise; getMarkWatchedKey: () => Promise; markActiveVideoWatched: () => Promise; @@ -386,9 +401,13 @@ export interface ElectronAPI { cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => Promise; onRuntimeOptionsChanged: (callback: (options: RuntimeOptionState[]) => void) => void; onOpenRuntimeOptions: (callback: () => void) => void; + onOpenSessionHelp: (callback: () => void) => void; + onOpenControllerSelect: (callback: () => void) => void; + onOpenControllerDebug: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void; onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; onOpenPlaylistBrowser: (callback: () => void) => void; + onSubtitleSidebarToggle: (callback: () => void) => void; onCancelYoutubeTrackPicker: (callback: () => void) => void; onKeyboardModeToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void; @@ -414,7 +433,8 @@ export interface ElectronAPI { | 'kiku' | 'controller-select' | 'controller-debug' - | 'subtitle-sidebar', + | 'subtitle-sidebar' + | 'session-help', ) => void; notifyOverlayModalOpened: ( modal: @@ -426,7 +446,8 @@ export interface ElectronAPI { | 'kiku' | 'controller-select' | 'controller-debug' - | 'subtitle-sidebar', + | 'subtitle-sidebar' + | 'session-help', ) => void; reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void; onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void; diff --git a/src/types/session-bindings.ts b/src/types/session-bindings.ts new file mode 100644 index 00000000..9b510bc8 --- /dev/null +++ b/src/types/session-bindings.ts @@ -0,0 +1,75 @@ +export type SessionKeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta'; + +export type SessionActionId = + | 'toggleStatsOverlay' + | 'toggleVisibleOverlay' + | 'copySubtitle' + | 'copySubtitleMultiple' + | 'updateLastCardFromClipboard' + | 'triggerFieldGrouping' + | 'triggerSubsync' + | 'mineSentence' + | 'mineSentenceMultiple' + | 'toggleSecondarySub' + | 'toggleSubtitleSidebar' + | 'markAudioCard' + | 'openRuntimeOptions' + | 'openSessionHelp' + | 'openControllerSelect' + | 'openControllerDebug' + | 'openJimaku' + | 'openYoutubePicker' + | 'openPlaylistBrowser' + | 'replayCurrentSubtitle' + | 'playNextSubtitle' + | 'shiftSubDelayPrevLine' + | 'shiftSubDelayNextLine' + | 'cycleRuntimeOption'; + +export interface SessionKeySpec { + code: string; + modifiers: SessionKeyModifier[]; +} + +export interface SessionBindingWarning { + kind: 'unsupported' | 'conflict' | 'deprecated-config'; + path: string; + message: string; + value: unknown; + conflictingPaths?: string[]; +} + +export interface SessionActionPayload { + count?: number; + runtimeOptionId?: string; + direction?: 1 | -1; +} + +type CompiledSessionBindingBase = { + sourcePath: string; + originalKey: string; + key: SessionKeySpec; +}; + +export interface CompiledMpvCommandBinding extends CompiledSessionBindingBase { + actionType: 'mpv-command'; + command: (string | number)[]; +} + +export interface CompiledSessionActionBinding extends CompiledSessionBindingBase { + actionType: 'session-action'; + actionId: SessionActionId; + payload?: SessionActionPayload; +} + +export type CompiledSessionBinding = + | CompiledMpvCommandBinding + | CompiledSessionActionBinding; + +export interface PluginSessionBindingsArtifact { + version: 1; + generatedAt: string; + numericSelectionTimeoutMs: number; + bindings: CompiledSessionBinding[]; + warnings: SessionBindingWarning[]; +} diff --git a/src/window-trackers/base-tracker.ts b/src/window-trackers/base-tracker.ts index d19172c3..15b5d9f5 100644 --- a/src/window-trackers/base-tracker.ts +++ b/src/window-trackers/base-tracker.ts @@ -62,6 +62,10 @@ export abstract class BaseWindowTracker { return this.targetWindowFocused; } + isTargetWindowMinimized(): boolean { + return false; + } + protected updateTargetWindowFocused(focused: boolean): void { if (this.targetWindowFocused === focused) { return; @@ -75,11 +79,11 @@ export abstract class BaseWindowTracker { this.updateTargetWindowFocused(focused); } - protected updateGeometry(newGeometry: WindowGeometry | null): void { + protected updateGeometry(newGeometry: WindowGeometry | null, initialFocused = true): void { if (newGeometry) { if (!this.windowFound) { this.windowFound = true; - this.updateTargetWindowFocused(true); + this.updateTargetWindowFocused(initialFocused); if (this.onWindowFound) this.onWindowFound(newGeometry); } diff --git a/src/window-trackers/mpv-socket-match.test.ts b/src/window-trackers/mpv-socket-match.test.ts new file mode 100644 index 00000000..bec350d8 --- /dev/null +++ b/src/window-trackers/mpv-socket-match.test.ts @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { filterMpvPollResultBySocketPath, matchesMpvSocketPathInCommandLine } from './mpv-socket-match'; +import type { MpvPollResult } from './win32'; + +function createPollResult(commandLines: Array): MpvPollResult { + return { + matches: commandLines.map((commandLine, index) => ({ + hwnd: index + 1, + bounds: { x: index * 10, y: 0, width: 1280, height: 720 }, + area: 1280 * 720, + isForeground: index === 0, + commandLine, + })), + focusState: true, + windowState: 'visible', + }; +} + +test('matchesMpvSocketPathInCommandLine accepts equals and space-delimited socket flags', () => { + assert.equal( + matchesMpvSocketPathInCommandLine( + 'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video.mkv', + '\\\\.\\pipe\\subminer-a', + ), + true, + ); + assert.equal( + matchesMpvSocketPathInCommandLine( + 'mpv.exe --input-ipc-server "\\\\.\\pipe\\subminer-b" video.mkv', + '\\\\.\\pipe\\subminer-b', + ), + true, + ); + assert.equal( + matchesMpvSocketPathInCommandLine( + 'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video.mkv', + '\\\\.\\pipe\\subminer-b', + ), + false, + ); +}); + +test('filterMpvPollResultBySocketPath keeps only matches for the requested socket path', () => { + const result = filterMpvPollResultBySocketPath( + createPollResult([ + 'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-a video-a.mkv', + 'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-b video-b.mkv', + null, + ]), + '\\\\.\\pipe\\subminer-b', + ); + + assert.deepEqual(result.matches.map((match) => match.hwnd), [2]); + assert.equal(result.windowState, 'visible'); +}); + +test('matchesMpvSocketPathInCommandLine rejects socket-path prefix matches', () => { + assert.equal( + matchesMpvSocketPathInCommandLine( + 'mpv.exe --input-ipc-server=\\\\.\\pipe\\subminer-10 video.mkv', + '\\\\.\\pipe\\subminer-1', + ), + false, + ); +}); diff --git a/src/window-trackers/mpv-socket-match.ts b/src/window-trackers/mpv-socket-match.ts new file mode 100644 index 00000000..c8d893b9 --- /dev/null +++ b/src/window-trackers/mpv-socket-match.ts @@ -0,0 +1,41 @@ +import type { MpvPollResult } from './win32'; + +function escapeRegex(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function matchesMpvSocketPathInCommandLine( + commandLine: string, + targetSocketPath: string, +): boolean { + if (!commandLine || !targetSocketPath) { + return false; + } + + const escapedSocketPath = escapeRegex(targetSocketPath); + return new RegExp( + `(?:^|\\s)--input-ipc-server(?:=|\\s+)(?:"${escapedSocketPath}"|${escapedSocketPath})(?=\\s|$)`, + 'i', + ).test(commandLine); +} + +export function filterMpvPollResultBySocketPath( + result: MpvPollResult, + targetSocketPath?: string | null, +): MpvPollResult { + if (!targetSocketPath) { + return result; + } + + const matches = result.matches.filter( + (match) => + typeof match.commandLine === 'string' && + matchesMpvSocketPathInCommandLine(match.commandLine, targetSocketPath), + ); + + return { + matches, + focusState: matches.some((match) => match.isForeground), + windowState: matches.length > 0 ? 'visible' : 'not-found', + }; +} diff --git a/src/window-trackers/win32.ts b/src/window-trackers/win32.ts new file mode 100644 index 00000000..8767b55f --- /dev/null +++ b/src/window-trackers/win32.ts @@ -0,0 +1,377 @@ +import { execFileSync } from 'node:child_process'; +import koffi from 'koffi'; +import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match'; + +const user32 = koffi.load('user32.dll'); +const dwmapi = koffi.load('dwmapi.dll'); +const kernel32 = koffi.load('kernel32.dll'); + +const RECT = koffi.struct('RECT', { + Left: 'int', + Top: 'int', + Right: 'int', + Bottom: 'int', +}); + +const MARGINS = koffi.struct('MARGINS', { + cxLeftWidth: 'int', + cxRightWidth: 'int', + cyTopHeight: 'int', + cyBottomHeight: 'int', +}); + +const WNDENUMPROC = koffi.proto('bool __stdcall WNDENUMPROC(intptr hwnd, intptr lParam)'); + +const EnumWindows = user32.func('bool __stdcall EnumWindows(WNDENUMPROC *cb, intptr lParam)'); +const IsWindowVisible = user32.func('bool __stdcall IsWindowVisible(intptr hwnd)'); +const IsIconic = user32.func('bool __stdcall IsIconic(intptr hwnd)'); +const GetForegroundWindow = user32.func('intptr __stdcall GetForegroundWindow()'); +const SetWindowPos = user32.func( + 'bool __stdcall SetWindowPos(intptr hwnd, intptr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags)', +); +const GetWindowThreadProcessId = user32.func( + 'uint __stdcall GetWindowThreadProcessId(intptr hwnd, _Out_ uint *lpdwProcessId)', +); +const GetWindowLongW = user32.func('int __stdcall GetWindowLongW(intptr hwnd, int nIndex)'); +const SetWindowLongPtrW = user32.func( + 'intptr __stdcall SetWindowLongPtrW(intptr hwnd, int nIndex, intptr dwNewLong)', +); +const GetWindowFn = user32.func('intptr __stdcall GetWindow(intptr hwnd, uint uCmd)'); +const GetWindowRect = user32.func('bool __stdcall GetWindowRect(intptr hwnd, _Out_ RECT *lpRect)'); + +const DwmGetWindowAttribute = dwmapi.func( + 'int __stdcall DwmGetWindowAttribute(intptr hwnd, uint dwAttribute, _Out_ RECT *pvAttribute, uint cbAttribute)', +); +const DwmExtendFrameIntoClientArea = dwmapi.func( + 'int __stdcall DwmExtendFrameIntoClientArea(intptr hwnd, MARGINS *pMarInset)', +); + +const OpenProcess = kernel32.func( + 'intptr __stdcall OpenProcess(uint dwDesiredAccess, bool bInheritHandle, uint dwProcessId)', +); +const CloseHandle = kernel32.func('bool __stdcall CloseHandle(intptr hObject)'); +const GetLastError = kernel32.func('uint __stdcall GetLastError()'); +const SetLastError = kernel32.func('void __stdcall SetLastError(uint dwErrCode)'); +const QueryFullProcessImageNameW = kernel32.func( + 'bool __stdcall QueryFullProcessImageNameW(intptr hProcess, uint dwFlags, _Out_ uint16 *lpExeName, _Inout_ uint *lpdwSize)', +); + +const GWL_EXSTYLE = -20; +const WS_EX_TOPMOST = 0x00000008; +const GWLP_HWNDPARENT = -8; +const GW_HWNDPREV = 3; +const DWMWA_EXTENDED_FRAME_BOUNDS = 9; +const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; +const SWP_NOSIZE = 0x0001; +const SWP_NOMOVE = 0x0002; +const SWP_NOACTIVATE = 0x0010; +const SWP_NOOWNERZORDER = 0x0200; +const SWP_FLAGS = SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE | SWP_NOOWNERZORDER; +const HWND_TOP = 0; +const HWND_BOTTOM = 1; +const HWND_TOPMOST = -1; +const HWND_NOTOPMOST = -2; + +function extendOverlayFrameIntoClientArea(overlayHwnd: number): void { + const hr = DwmExtendFrameIntoClientArea(overlayHwnd, { + cxLeftWidth: -1, + cxRightWidth: -1, + cyTopHeight: -1, + cyBottomHeight: -1, + }); + if (hr !== 0) { + throw new Error(`DwmExtendFrameIntoClientArea failed (${hr})`); + } +} + +function resetLastError(): void { + SetLastError(0); +} + +function assertSetWindowLongPtrSucceeded(operation: string, result: number): void { + if (result !== 0) { + return; + } + + if (GetLastError() === 0) { + return; + } + + throw new Error(`${operation} failed (${GetLastError()})`); +} + +function assertSetWindowPosSucceeded(operation: string, result: boolean): void { + if (result) { + return; + } + + throw new Error(`${operation} failed (${GetLastError()})`); +} + +function assertGetWindowLongSucceeded(operation: string, result: number): number { + if (result !== 0 || GetLastError() === 0) { + return result; + } + + throw new Error(`${operation} failed (${GetLastError()})`); +} + +export interface WindowBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface MpvWindowMatch { + hwnd: number; + bounds: WindowBounds; + area: number; + isForeground: boolean; + commandLine?: string | null; +} + +export interface MpvPollResult { + matches: MpvWindowMatch[]; + focusState: boolean; + windowState: 'visible' | 'minimized' | 'not-found'; +} + +function getWindowBounds(hwnd: number): WindowBounds | null { + const rect = { Left: 0, Top: 0, Right: 0, Bottom: 0 }; + const hr = DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, rect, koffi.sizeof(RECT)); + if (hr !== 0) { + if (!GetWindowRect(hwnd, rect)) { + return null; + } + } + + const width = rect.Right - rect.Left; + const height = rect.Bottom - rect.Top; + if (width <= 0 || height <= 0) return null; + + return { x: rect.Left, y: rect.Top, width, height }; +} + +function getProcessNameByPid(pid: number): string | null { + const hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid); + if (!hProcess) return null; + + try { + const buffer = new Uint16Array(260); + const size = new Uint32Array([260]); + + if (!QueryFullProcessImageNameW(hProcess, 0, buffer, size)) { + return null; + } + + const fullPath = String.fromCharCode(...buffer.slice(0, size[0])); + const fileName = fullPath.split('\\').pop() || ''; + return fileName.replace(/\.exe$/i, ''); + } finally { + CloseHandle(hProcess); + } +} + +const processCommandLineCache = new Map(); + +function getProcessCommandLineByPid(pid: number): string | null { + if (processCommandLineCache.has(pid)) { + return processCommandLineCache.get(pid) ?? null; + } + + let commandLine: string | null = null; + try { + const output = execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + `$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`, + ], + { + encoding: 'utf8', + windowsHide: true, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 1500, + }, + ).trim(); + commandLine = output.length > 0 ? output : null; + } catch { + commandLine = null; + } + + if (commandLine !== null) { + processCommandLineCache.set(pid, commandLine); + } else { + processCommandLineCache.delete(pid); + } + return commandLine; +} + +export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult { + const foregroundHwnd = GetForegroundWindow(); + const matches: MpvWindowMatch[] = []; + let hasMinimized = false; + let hasFocused = false; + const processNameCache = new Map(); + const processCommandLineLookupCache = new Map(); + + const cb = koffi.register((hwnd: number, _lParam: number) => { + if (!IsWindowVisible(hwnd)) return true; + + const pid = new Uint32Array(1); + GetWindowThreadProcessId(hwnd, pid); + const pidValue = pid[0]!; + if (pidValue === 0) return true; + + let processName = processNameCache.get(pidValue); + if (processName === undefined) { + processName = getProcessNameByPid(pidValue); + processNameCache.set(pidValue, processName); + } + + if (!processName || processName.toLowerCase() !== 'mpv') return true; + + let commandLine: string | null = null; + if (targetSocketPath) { + commandLine = processCommandLineLookupCache.get(pidValue) ?? null; + if (!processCommandLineLookupCache.has(pidValue)) { + commandLine = getProcessCommandLineByPid(pidValue); + processCommandLineLookupCache.set(pidValue, commandLine); + } + if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) { + return true; + } + } + + if (IsIconic(hwnd)) { + hasMinimized = true; + return true; + } + + const bounds = getWindowBounds(hwnd); + if (!bounds) return true; + + const isForeground = foregroundHwnd !== 0 && hwnd === foregroundHwnd; + if (isForeground) hasFocused = true; + + matches.push({ + hwnd, + bounds, + area: bounds.width * bounds.height, + isForeground, + commandLine, + }); + + return true; + }, koffi.pointer(WNDENUMPROC)); + + try { + EnumWindows(cb, 0); + } finally { + koffi.unregister(cb); + } + + return { + matches, + focusState: hasFocused, + windowState: matches.length > 0 ? 'visible' : hasMinimized ? 'minimized' : 'not-found', + }; +} + +export function getForegroundProcessName(): string | null { + const foregroundHwnd = GetForegroundWindow(); + if (!foregroundHwnd) return null; + + const pid = new Uint32Array(1); + GetWindowThreadProcessId(foregroundHwnd, pid); + const pidValue = pid[0]!; + if (pidValue === 0) return null; + + return getProcessNameByPid(pidValue); +} + +export function setOverlayOwner(overlayHwnd: number, mpvHwnd: number): void { + resetLastError(); + const result = SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd); + assertSetWindowLongPtrSucceeded('setOverlayOwner', result); + extendOverlayFrameIntoClientArea(overlayHwnd); +} + +export function ensureOverlayTransparency(overlayHwnd: number): void { + extendOverlayFrameIntoClientArea(overlayHwnd); +} + +export function clearOverlayOwner(overlayHwnd: number): void { + resetLastError(); + const result = SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, 0); + assertSetWindowLongPtrSucceeded('clearOverlayOwner', result); +} + +export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void { + resetLastError(); + const ownerResult = SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd); + assertSetWindowLongPtrSucceeded('bindOverlayAboveMpv owner assignment', ownerResult); + + resetLastError(); + const mpvExStyle = assertGetWindowLongSucceeded( + 'bindOverlayAboveMpv target window style', + GetWindowLongW(mpvHwnd, GWL_EXSTYLE), + ); + const mpvIsTopmost = (mpvExStyle & WS_EX_TOPMOST) !== 0; + + resetLastError(); + const overlayExStyle = assertGetWindowLongSucceeded( + 'bindOverlayAboveMpv overlay window style', + GetWindowLongW(overlayHwnd, GWL_EXSTYLE), + ); + const overlayIsTopmost = (overlayExStyle & WS_EX_TOPMOST) !== 0; + + if (mpvIsTopmost && !overlayIsTopmost) { + resetLastError(); + const topmostResult = SetWindowPos(overlayHwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_FLAGS); + assertSetWindowPosSucceeded('bindOverlayAboveMpv topmost adjustment', topmostResult); + } else if (!mpvIsTopmost && overlayIsTopmost) { + resetLastError(); + const notTopmostResult = SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS); + assertSetWindowPosSucceeded('bindOverlayAboveMpv notopmost adjustment', notTopmostResult); + } + + const windowAboveMpv = GetWindowFn(mpvHwnd, GW_HWNDPREV); + if (windowAboveMpv !== 0 && windowAboveMpv === overlayHwnd) return; + + let insertAfter = HWND_TOP; + if (windowAboveMpv !== 0) { + try { + resetLastError(); + const aboveExStyle = assertGetWindowLongSucceeded( + 'bindOverlayAboveMpv window above style', + GetWindowLongW(windowAboveMpv, GWL_EXSTYLE), + ); + const aboveIsTopmost = (aboveExStyle & WS_EX_TOPMOST) !== 0; + if (aboveIsTopmost === mpvIsTopmost) { + insertAfter = windowAboveMpv; + } + } catch { + insertAfter = HWND_TOP; + } + } + + resetLastError(); + const positionResult = SetWindowPos(overlayHwnd, insertAfter, 0, 0, 0, 0, SWP_FLAGS); + assertSetWindowPosSucceeded('bindOverlayAboveMpv z-order adjustment', positionResult); +} + +export function lowerOverlay(overlayHwnd: number): void { + resetLastError(); + const notTopmostResult = SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS); + assertSetWindowPosSucceeded('lowerOverlay notopmost adjustment', notTopmostResult); + + resetLastError(); + const bottomResult = SetWindowPos(overlayHwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_FLAGS); + assertSetWindowPosSucceeded('lowerOverlay bottom adjustment', bottomResult); +} diff --git a/src/window-trackers/windows-helper.test.ts b/src/window-trackers/windows-helper.test.ts index 76712c2a..88a0c4ab 100644 --- a/src/window-trackers/windows-helper.test.ts +++ b/src/window-trackers/windows-helper.test.ts @@ -1,111 +1,60 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { - parseWindowTrackerHelperFocusState, - parseWindowTrackerHelperOutput, - resolveWindowsTrackerHelper, -} from './windows-helper'; +import { findWindowsMpvTargetWindowHandle } from './windows-helper'; +import type { MpvPollResult } from './win32'; -test('parseWindowTrackerHelperOutput parses helper geometry output', () => { - assert.deepEqual(parseWindowTrackerHelperOutput('120,240,1280,720'), { - x: 120, - y: 240, - width: 1280, - height: 720, - }); -}); - -test('parseWindowTrackerHelperOutput returns null for misses and invalid payloads', () => { - assert.equal(parseWindowTrackerHelperOutput('not-found'), null); - assert.equal(parseWindowTrackerHelperOutput('1,2,3'), null); - assert.equal(parseWindowTrackerHelperOutput('1,2,0,4'), null); -}); - -test('parseWindowTrackerHelperFocusState parses helper stderr metadata', () => { - assert.equal(parseWindowTrackerHelperFocusState('focus=focused'), true); - assert.equal(parseWindowTrackerHelperFocusState('focus=not-focused'), false); - assert.equal(parseWindowTrackerHelperFocusState('warning\nfocus=focused\nnote'), true); - assert.equal(parseWindowTrackerHelperFocusState(''), null); -}); - -test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => { - const helper = resolveWindowsTrackerHelper({ - dirname: 'C:\\repo\\dist\\window-trackers', - resourcesPath: 'C:\\repo\\resources', - existsSync: (candidate) => - candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe', - helperModeEnv: 'auto', - }); - - assert.deepEqual(helper, { - kind: 'native', - command: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe', - args: [], - helperPath: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe', - }); -}); - -test('resolveWindowsTrackerHelper auto mode falls back to powershell helper', () => { - const helper = resolveWindowsTrackerHelper({ - dirname: 'C:\\repo\\dist\\window-trackers', - resourcesPath: 'C:\\repo\\resources', - existsSync: (candidate) => - candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1', - helperModeEnv: 'auto', - }); - - assert.deepEqual(helper, { - kind: 'powershell', - command: 'powershell.exe', - args: [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-File', - 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1', +test('findWindowsMpvTargetWindowHandle prefers the focused mpv window', () => { + const result: MpvPollResult = { + matches: [ + { + hwnd: 111, + bounds: { x: 0, y: 0, width: 1280, height: 720 }, + area: 1280 * 720, + isForeground: false, + }, + { + hwnd: 222, + bounds: { x: 10, y: 10, width: 800, height: 600 }, + area: 800 * 600, + isForeground: true, + }, ], - helperPath: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1', - }); + focusState: true, + windowState: 'visible', + }; + + assert.equal(findWindowsMpvTargetWindowHandle(result), 222); }); -test('resolveWindowsTrackerHelper explicit powershell mode ignores native helper', () => { - const helper = resolveWindowsTrackerHelper({ - dirname: 'C:\\repo\\dist\\window-trackers', - resourcesPath: 'C:\\repo\\resources', - existsSync: (candidate) => - candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe' || - candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1', - helperModeEnv: 'powershell', - }); +test('findWindowsMpvTargetWindowHandle falls back to the largest visible mpv window', () => { + const result: MpvPollResult = { + matches: [ + { + hwnd: 111, + bounds: { x: 0, y: 0, width: 640, height: 360 }, + area: 640 * 360, + isForeground: false, + }, + { + hwnd: 222, + bounds: { x: 10, y: 10, width: 1920, height: 1080 }, + area: 1920 * 1080, + isForeground: false, + }, + ], + focusState: false, + windowState: 'visible', + }; - assert.equal(helper?.kind, 'powershell'); - assert.equal(helper?.helperPath, 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1'); + assert.equal(findWindowsMpvTargetWindowHandle(result), 222); }); -test('resolveWindowsTrackerHelper explicit native mode fails cleanly when helper is missing', () => { - const helper = resolveWindowsTrackerHelper({ - dirname: 'C:\\repo\\dist\\window-trackers', - resourcesPath: 'C:\\repo\\resources', - existsSync: () => false, - helperModeEnv: 'native', - }); +test('findWindowsMpvTargetWindowHandle returns null when no mpv windows are visible', () => { + const result: MpvPollResult = { + matches: [], + focusState: false, + windowState: 'not-found', + }; - assert.equal(helper, null); -}); - -test('resolveWindowsTrackerHelper explicit helper path overrides default search', () => { - const helper = resolveWindowsTrackerHelper({ - dirname: 'C:\\repo\\dist\\window-trackers', - resourcesPath: 'C:\\repo\\resources', - existsSync: (candidate) => candidate === 'D:\\custom\\tracker.ps1', - helperModeEnv: 'auto', - helperPathEnv: 'D:\\custom\\tracker.ps1', - }); - - assert.deepEqual(helper, { - kind: 'powershell', - command: 'powershell.exe', - args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'D:\\custom\\tracker.ps1'], - helperPath: 'D:\\custom\\tracker.ps1', - }); + assert.equal(findWindowsMpvTargetWindowHandle(result), null); }); diff --git a/src/window-trackers/windows-helper.ts b/src/window-trackers/windows-helper.ts index cf91e27a..c71787ff 100644 --- a/src/window-trackers/windows-helper.ts +++ b/src/window-trackers/windows-helper.ts @@ -16,269 +16,62 @@ along with this program. If not, see . */ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import type { WindowGeometry } from '../types'; -import { createLogger } from '../logger'; +import type { MpvPollResult } from './win32'; -const log = createLogger('tracker').child('windows-helper'); - -export type WindowsTrackerHelperKind = 'powershell' | 'native'; -export type WindowsTrackerHelperMode = 'auto' | 'powershell' | 'native'; - -export type WindowsTrackerHelperLaunchSpec = { - kind: WindowsTrackerHelperKind; - command: string; - args: string[]; - helperPath: string; -}; - -type ResolveWindowsTrackerHelperOptions = { - dirname?: string; - resourcesPath?: string; - helperModeEnv?: string | undefined; - helperPathEnv?: string | undefined; - existsSync?: (candidate: string) => boolean; - mkdirSync?: (candidate: string, options: { recursive: true }) => void; - copyFileSync?: (source: string, destination: string) => void; -}; - -const windowsPath = path.win32; - -function normalizeHelperMode(value: string | undefined): WindowsTrackerHelperMode { - const normalized = value?.trim().toLowerCase(); - if (normalized === 'powershell' || normalized === 'native') { - return normalized; - } - return 'auto'; +function loadWin32(): typeof import('./win32') { + return require('./win32') as typeof import('./win32'); } -function inferHelperKindFromPath(helperPath: string): WindowsTrackerHelperKind | null { - const normalized = helperPath.trim().toLowerCase(); - if (normalized.endsWith('.exe')) return 'native'; - if (normalized.endsWith('.ps1')) return 'powershell'; - return null; +export function findWindowsMpvTargetWindowHandle(result?: MpvPollResult): number | null { + const poll = result ?? loadWin32().findMpvWindows(); + const focused = poll.matches.find((match) => match.isForeground); + const best = + focused ?? [...poll.matches].sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0]; + return best?.hwnd ?? null; } -function materializeAsarHelper( - sourcePath: string, - kind: WindowsTrackerHelperKind, - deps: Required>, -): string | null { - if (!sourcePath.includes('.asar')) { - return sourcePath; - } - - const fileName = kind === 'native' ? 'get-mpv-window-windows.exe' : 'get-mpv-window-windows.ps1'; - const targetDir = windowsPath.join(os.tmpdir(), 'subminer', 'helpers'); - const targetPath = windowsPath.join(targetDir, fileName); - +export function setWindowsOverlayOwner(overlayHwnd: number, mpvHwnd: number): boolean { try { - deps.mkdirSync(targetDir, { recursive: true }); - deps.copyFileSync(sourcePath, targetPath); - log.info(`Materialized Windows helper from asar: ${targetPath}`); - return targetPath; - } catch (error) { - log.warn(`Failed to materialize Windows helper from asar: ${sourcePath}`, error); - return null; - } -} - -function createLaunchSpec( - helperPath: string, - kind: WindowsTrackerHelperKind, -): WindowsTrackerHelperLaunchSpec { - if (kind === 'native') { - return { - kind, - command: helperPath, - args: [], - helperPath, - }; - } - - return { - kind, - command: 'powershell.exe', - args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', helperPath], - helperPath, - }; -} - -function normalizeHelperPathOverride( - helperPathEnv: string | undefined, - mode: WindowsTrackerHelperMode, -): { path: string; kind: WindowsTrackerHelperKind } | null { - const helperPath = helperPathEnv?.trim(); - if (!helperPath) { - return null; - } - - const inferredKind = inferHelperKindFromPath(helperPath); - const kind = mode === 'auto' ? inferredKind : mode; - if (!kind) { - log.warn( - `Ignoring SUBMINER_WINDOWS_TRACKER_HELPER_PATH with unsupported extension: ${helperPath}`, - ); - return null; - } - - return { path: helperPath, kind }; -} - -function getHelperCandidates( - dirname: string, - resourcesPath: string | undefined, -): Array<{ - path: string; - kind: WindowsTrackerHelperKind; -}> { - const scriptFileBase = 'get-mpv-window-windows'; - const candidates: Array<{ path: string; kind: WindowsTrackerHelperKind }> = []; - - if (resourcesPath) { - candidates.push({ - path: windowsPath.join(resourcesPath, 'scripts', `${scriptFileBase}.exe`), - kind: 'native', - }); - candidates.push({ - path: windowsPath.join(resourcesPath, 'scripts', `${scriptFileBase}.ps1`), - kind: 'powershell', - }); - } - - candidates.push({ - path: windowsPath.join(dirname, '..', 'scripts', `${scriptFileBase}.exe`), - kind: 'native', - }); - candidates.push({ - path: windowsPath.join(dirname, '..', 'scripts', `${scriptFileBase}.ps1`), - kind: 'powershell', - }); - candidates.push({ - path: windowsPath.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.exe`), - kind: 'native', - }); - candidates.push({ - path: windowsPath.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.ps1`), - kind: 'powershell', - }); - - return candidates; -} - -export function parseWindowTrackerHelperOutput(output: string): WindowGeometry | null { - const result = output.trim(); - if (!result || result === 'not-found') { - return null; - } - - const parts = result.split(','); - if (parts.length !== 4) { - return null; - } - - const [xText, yText, widthText, heightText] = parts; - const x = Number.parseInt(xText!, 10); - const y = Number.parseInt(yText!, 10); - const width = Number.parseInt(widthText!, 10); - const height = Number.parseInt(heightText!, 10); - if ( - !Number.isFinite(x) || - !Number.isFinite(y) || - !Number.isFinite(width) || - !Number.isFinite(height) || - width <= 0 || - height <= 0 - ) { - return null; - } - - return { x, y, width, height }; -} - -export function parseWindowTrackerHelperFocusState(output: string): boolean | null { - const focusLine = output - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.startsWith('focus=')); - - if (!focusLine) { - return null; - } - - const value = focusLine.slice('focus='.length).trim().toLowerCase(); - if (value === 'focused') { + loadWin32().setOverlayOwner(overlayHwnd, mpvHwnd); return true; - } - if (value === 'not-focused') { + } catch { return false; } - - return null; } -export function resolveWindowsTrackerHelper( - options: ResolveWindowsTrackerHelperOptions = {}, -): WindowsTrackerHelperLaunchSpec | null { - const existsSync = options.existsSync ?? fs.existsSync; - const mkdirSync = options.mkdirSync ?? fs.mkdirSync; - const copyFileSync = options.copyFileSync ?? fs.copyFileSync; - const dirname = options.dirname ?? __dirname; - const resourcesPath = options.resourcesPath ?? process.resourcesPath; - const mode = normalizeHelperMode( - options.helperModeEnv ?? process.env.SUBMINER_WINDOWS_TRACKER_HELPER, - ); - const override = normalizeHelperPathOverride( - options.helperPathEnv ?? process.env.SUBMINER_WINDOWS_TRACKER_HELPER_PATH, - mode, - ); - - if (override) { - if (!existsSync(override.path)) { - log.warn(`Configured Windows tracker helper path does not exist: ${override.path}`); - return null; - } - const helperPath = materializeAsarHelper(override.path, override.kind, { - mkdirSync, - copyFileSync, - }); - return helperPath ? createLaunchSpec(helperPath, override.kind) : null; +export function ensureWindowsOverlayTransparency(overlayHwnd: number): boolean { + try { + loadWin32().ensureOverlayTransparency(overlayHwnd); + return true; + } catch { + return false; + } +} + +export function bindWindowsOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): boolean { + try { + const win32 = loadWin32(); + win32.bindOverlayAboveMpv(overlayHwnd, mpvHwnd); + win32.ensureOverlayTransparency(overlayHwnd); + return true; + } catch { + return false; + } +} + +export function clearWindowsOverlayOwner(overlayHwnd: number): boolean { + try { + loadWin32().clearOverlayOwner(overlayHwnd); + return true; + } catch { + return false; + } +} + +export function getWindowsForegroundProcessName(): string | null { + try { + return loadWin32().getForegroundProcessName(); + } catch { + return null; } - - const candidates = getHelperCandidates(dirname, resourcesPath); - const orderedCandidates = - mode === 'powershell' - ? candidates.filter((candidate) => candidate.kind === 'powershell') - : mode === 'native' - ? candidates.filter((candidate) => candidate.kind === 'native') - : candidates; - - for (const candidate of orderedCandidates) { - if (!existsSync(candidate.path)) { - continue; - } - - const helperPath = materializeAsarHelper(candidate.path, candidate.kind, { - mkdirSync, - copyFileSync, - }); - if (!helperPath) { - continue; - } - - log.info(`Using Windows helper (${candidate.kind}): ${helperPath}`); - return createLaunchSpec(helperPath, candidate.kind); - } - - if (mode === 'native') { - log.warn('Windows native tracker helper requested but no helper was found.'); - } else if (mode === 'powershell') { - log.warn('Windows PowerShell tracker helper requested but no helper was found.'); - } else { - log.warn('Windows tracker helper not found.'); - } - - return null; } diff --git a/src/window-trackers/windows-tracker.test.ts b/src/window-trackers/windows-tracker.test.ts index 2b643bd4..da353a27 100644 --- a/src/window-trackers/windows-tracker.test.ts +++ b/src/window-trackers/windows-tracker.test.ts @@ -1,56 +1,65 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { WindowsWindowTracker } from './windows-tracker'; +import type { MpvPollResult } from './win32'; -test('WindowsWindowTracker skips overlapping polls while helper is in flight', async () => { - let helperCalls = 0; - let release: (() => void) | undefined; - const gate = new Promise((resolve) => { - release = resolve; - }); +function mpvVisible( + overrides: Partial = {}, +): MpvPollResult { + return { + matches: [ + { + hwnd: 12345, + bounds: { + x: overrides.x ?? 0, + y: overrides.y ?? 0, + width: overrides.width ?? 1280, + height: overrides.height ?? 720, + }, + area: (overrides.width ?? 1280) * (overrides.height ?? 720), + isForeground: overrides.focused ?? true, + }, + ], + focusState: overrides.focused ?? true, + windowState: 'visible', + }; +} - const tracker = new WindowsWindowTracker(undefined, { - resolveHelper: () => ({ - kind: 'powershell', - command: 'powershell.exe', - args: ['-File', 'helper.ps1'], - helperPath: 'helper.ps1', - }), - runHelper: async () => { - helperCalls += 1; - await gate; - return { - stdout: '0,0,640,360', - stderr: 'focus=focused', - }; +const mpvNotFound: MpvPollResult = { + matches: [], + focusState: false, + windowState: 'not-found', +}; + +const mpvMinimized: MpvPollResult = { + matches: [], + focusState: false, + windowState: 'minimized', +}; + +test('WindowsWindowTracker skips overlapping polls while poll is in flight', () => { + let pollCalls = 0; + let tracker: WindowsWindowTracker; + tracker = new WindowsWindowTracker(undefined, { + pollMpvWindows: () => { + pollCalls += 1; + if (pollCalls === 1) { + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + } + return mpvVisible(); }, }); (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); - (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); - assert.equal(helperCalls, 1); - - assert.ok(release); - release(); - await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(pollCalls, 1); }); -test('WindowsWindowTracker updates geometry from helper output', async () => { +test('WindowsWindowTracker updates geometry from poll output', () => { const tracker = new WindowsWindowTracker(undefined, { - resolveHelper: () => ({ - kind: 'powershell', - command: 'powershell.exe', - args: ['-File', 'helper.ps1'], - helperPath: 'helper.ps1', - }), - runHelper: async () => ({ - stdout: '10,20,1280,720', - stderr: 'focus=focused', - }), + pollMpvWindows: () => mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), }); (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); - await new Promise((resolve) => setTimeout(resolve, 0)); assert.deepEqual(tracker.getGeometry(), { x: 10, @@ -61,59 +70,196 @@ test('WindowsWindowTracker updates geometry from helper output', async () => { assert.equal(tracker.isTargetWindowFocused(), true); }); -test('WindowsWindowTracker clears geometry for helper misses', async () => { +test('WindowsWindowTracker preserves an unfocused initial match', () => { const tracker = new WindowsWindowTracker(undefined, { - resolveHelper: () => ({ - kind: 'powershell', - command: 'powershell.exe', - args: ['-File', 'helper.ps1'], - helperPath: 'helper.ps1', - }), - runHelper: async () => ({ - stdout: 'not-found', - stderr: 'focus=not-focused', - }), + pollMpvWindows: () => mpvVisible({ x: 10, y: 20, width: 1280, height: 720, focused: false }), + }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + + assert.deepEqual(tracker.getGeometry(), { + x: 10, + y: 20, + width: 1280, + height: 720, + }); + assert.equal(tracker.isTargetWindowFocused(), false); +}); + +test('WindowsWindowTracker clears geometry for poll misses', () => { + const tracker = new WindowsWindowTracker(undefined, { + pollMpvWindows: () => mpvNotFound, + trackingLossGraceMs: 0, }); (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); - await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(tracker.getGeometry(), null); assert.equal(tracker.isTargetWindowFocused(), false); }); -test('WindowsWindowTracker retries without socket filter when filtered helper lookup misses', async () => { - const helperCalls: Array = []; - const tracker = new WindowsWindowTracker('\\\\.\\pipe\\subminer-socket', { - resolveHelper: () => ({ - kind: 'powershell', - command: 'powershell.exe', - args: ['-File', 'helper.ps1'], - helperPath: 'helper.ps1', - }), - runHelper: async (_spec, _mode, targetMpvSocketPath) => { - helperCalls.push(targetMpvSocketPath); - if (targetMpvSocketPath) { - return { - stdout: 'not-found', - stderr: 'focus=not-focused', - }; - } - return { - stdout: '25,30,1440,810', - stderr: 'focus=focused', - }; - }, +test('WindowsWindowTracker keeps the last geometry through a single poll miss', () => { + let callIndex = 0; + const outputs = [ + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + mpvNotFound, + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + ]; + + const tracker = new WindowsWindowTracker(undefined, { + pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!, + trackingLossGraceMs: 0, }); (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); - await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); - assert.deepEqual(helperCalls, ['\\\\.\\pipe\\subminer-socket', null]); - assert.deepEqual(tracker.getGeometry(), { - x: 25, - y: 30, - width: 1440, - height: 810, - }); - assert.equal(tracker.isTargetWindowFocused(), true); + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); +}); + +test('WindowsWindowTracker drops tracking after grace window expires', () => { + let callIndex = 0; + let now = 1_000; + const outputs = [ + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + mpvNotFound, + mpvNotFound, + mpvNotFound, + mpvNotFound, + ]; + + const tracker = new WindowsWindowTracker(undefined, { + pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!, + now: () => now, + trackingLossGraceMs: 500, + }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), false); + assert.equal(tracker.getGeometry(), null); +}); + +test('WindowsWindowTracker keeps tracking through repeated poll misses inside grace window', () => { + let callIndex = 0; + let now = 1_000; + const outputs = [ + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + mpvNotFound, + mpvNotFound, + mpvNotFound, + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + ]; + + const tracker = new WindowsWindowTracker(undefined, { + pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!, + now: () => now, + trackingLossGraceMs: 1_500, + }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); +}); + +test('WindowsWindowTracker keeps tracking through a transient minimized report inside minimized grace window', () => { + let callIndex = 0; + let now = 1_000; + const outputs: MpvPollResult[] = [ + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + mpvMinimized, + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + ]; + + const tracker = new WindowsWindowTracker(undefined, { + pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!, + now: () => now, + minimizedTrackingLossGraceMs: 200, + }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 100; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); + + now += 100; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); +}); + +test('WindowsWindowTracker keeps tracking through repeated transient minimized reports inside minimized grace window', () => { + let callIndex = 0; + let now = 1_000; + const outputs: MpvPollResult[] = [ + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + mpvMinimized, + mpvMinimized, + mpvVisible({ x: 10, y: 20, width: 1280, height: 720 }), + ]; + + const tracker = new WindowsWindowTracker(undefined, { + pollMpvWindows: () => outputs[callIndex++] ?? outputs.at(-1)!, + now: () => now, + minimizedTrackingLossGraceMs: 500, + }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowMinimized(), true); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowMinimized(), true); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); + + now += 250; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowMinimized(), false); + assert.deepEqual(tracker.getGeometry(), { x: 10, y: 20, width: 1280, height: 720 }); }); diff --git a/src/window-trackers/windows-tracker.ts b/src/window-trackers/windows-tracker.ts index 3c07cca2..4152aaa6 100644 --- a/src/window-trackers/windows-tracker.ts +++ b/src/window-trackers/windows-tracker.ts @@ -16,80 +16,53 @@ along with this program. If not, see . */ -import { execFile, type ExecFileException } from 'child_process'; import { BaseWindowTracker } from './base-tracker'; -import { - parseWindowTrackerHelperFocusState, - parseWindowTrackerHelperOutput, - resolveWindowsTrackerHelper, - type WindowsTrackerHelperLaunchSpec, -} from './windows-helper'; +import type { WindowGeometry } from '../types'; +import type { MpvPollResult } from './win32'; import { createLogger } from '../logger'; const log = createLogger('tracker').child('windows'); -type WindowsTrackerRunnerResult = { - stdout: string; - stderr: string; -}; - type WindowsTrackerDeps = { - resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null; - runHelper?: ( - spec: WindowsTrackerHelperLaunchSpec, - mode: 'geometry', - targetMpvSocketPath: string | null, - ) => Promise; + pollMpvWindows?: () => MpvPollResult; + maxConsecutiveMisses?: number; + trackingLossGraceMs?: number; + minimizedTrackingLossGraceMs?: number; + now?: () => number; }; -function runHelperWithExecFile( - spec: WindowsTrackerHelperLaunchSpec, - mode: 'geometry', - targetMpvSocketPath: string | null, -): Promise { - return new Promise((resolve, reject) => { - const modeArgs = spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode]; - const args = targetMpvSocketPath - ? [...spec.args, ...modeArgs, targetMpvSocketPath] - : [...spec.args, ...modeArgs]; - execFile( - spec.command, - args, - { - encoding: 'utf-8', - timeout: 1000, - maxBuffer: 1024 * 1024, - windowsHide: true, - }, - (error: ExecFileException | null, stdout: string, stderr: string) => { - if (error) { - reject(Object.assign(error, { stderr })); - return; - } - resolve({ stdout, stderr }); - }, - ); - }); +function defaultPollMpvWindows(_targetMpvSocketPath?: string | null): MpvPollResult { + const win32 = require('./win32') as typeof import('./win32'); + return win32.findMpvWindows(_targetMpvSocketPath); } export class WindowsWindowTracker extends BaseWindowTracker { private pollInterval: ReturnType | null = null; private pollInFlight = false; - private helperSpec: WindowsTrackerHelperLaunchSpec | null; + private readonly pollMpvWindows: () => MpvPollResult; + private readonly maxConsecutiveMisses: number; + private readonly trackingLossGraceMs: number; + private readonly minimizedTrackingLossGraceMs: number; + private readonly now: () => number; + private lastPollErrorFingerprint: string | null = null; + private lastPollErrorLoggedAtMs = 0; + private consecutiveMisses = 0; + private trackingLossStartedAtMs: number | null = null; + private targetWindowMinimized = false; private readonly targetMpvSocketPath: string | null; - private readonly runHelper: ( - spec: WindowsTrackerHelperLaunchSpec, - mode: 'geometry', - targetMpvSocketPath: string | null, - ) => Promise; - private lastExecErrorFingerprint: string | null = null; - private lastExecErrorLoggedAtMs = 0; + private currentTargetWindowHwnd: number | null = null; - constructor(targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) { + constructor(_targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) { super(); - this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null; - this.helperSpec = deps.resolveHelper ? deps.resolveHelper() : resolveWindowsTrackerHelper(); - this.runHelper = deps.runHelper ?? runHelperWithExecFile; + this.targetMpvSocketPath = _targetMpvSocketPath?.trim() || null; + this.pollMpvWindows = deps.pollMpvWindows ?? (() => defaultPollMpvWindows(this.targetMpvSocketPath)); + this.maxConsecutiveMisses = Math.max(1, Math.floor(deps.maxConsecutiveMisses ?? 2)); + this.trackingLossGraceMs = Math.max(0, Math.floor(deps.trackingLossGraceMs ?? 1_500)); + this.minimizedTrackingLossGraceMs = Math.max( + 0, + Math.floor(deps.minimizedTrackingLossGraceMs ?? 500), + ); + this.now = deps.now ?? (() => Date.now()); } start(): void { @@ -104,72 +77,108 @@ export class WindowsWindowTracker extends BaseWindowTracker { } } - private maybeLogExecError(error: Error, stderr: string): void { - const now = Date.now(); - const fingerprint = `${error.message}|${stderr.trim()}`; - const shouldLog = - this.lastExecErrorFingerprint !== fingerprint || now - this.lastExecErrorLoggedAtMs >= 5000; - if (!shouldLog) { - return; - } - - this.lastExecErrorFingerprint = fingerprint; - this.lastExecErrorLoggedAtMs = now; - log.warn('Windows helper execution failed', { - helperPath: this.helperSpec?.helperPath ?? null, - helperKind: this.helperSpec?.kind ?? null, - error: error.message, - stderr: stderr.trim(), - }); + override isTargetWindowMinimized(): boolean { + return this.targetWindowMinimized; } - private async runHelperWithSocketFallback(): Promise { - if (!this.helperSpec) { - return { stdout: 'not-found', stderr: '' }; - } + getTargetWindowHandle(): number | null { + return this.currentTargetWindowHwnd; + } - try { - const primary = await this.runHelper(this.helperSpec, 'geometry', this.targetMpvSocketPath); - const primaryGeometry = parseWindowTrackerHelperOutput(primary.stdout); - if (primaryGeometry || !this.targetMpvSocketPath) { - return primary; - } - } catch (error) { - if (!this.targetMpvSocketPath) { - throw error; - } - } + private maybeLogPollError(error: Error): void { + const now = Date.now(); + const fingerprint = error.message; + const shouldLog = + this.lastPollErrorFingerprint !== fingerprint || now - this.lastPollErrorLoggedAtMs >= 5000; + if (!shouldLog) return; - return await this.runHelper(this.helperSpec, 'geometry', null); + this.lastPollErrorFingerprint = fingerprint; + this.lastPollErrorLoggedAtMs = now; + log.warn('Windows native poll failed', { error: error.message }); + } + + private resetTrackingLossState(): void { + this.consecutiveMisses = 0; + this.trackingLossStartedAtMs = null; + } + + private shouldDropTracking(graceMs = this.trackingLossGraceMs): boolean { + if (!this.isTracking()) { + return true; + } + if (graceMs === 0) { + return this.consecutiveMisses >= this.maxConsecutiveMisses; + } + if (this.trackingLossStartedAtMs === null) { + this.trackingLossStartedAtMs = this.now(); + return false; + } + return this.now() - this.trackingLossStartedAtMs > graceMs; + } + + private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void { + this.consecutiveMisses += 1; + if (this.shouldDropTracking(graceMs)) { + this.updateGeometry(null); + this.resetTrackingLossState(); + } + } + + private selectBestMatch( + result: MpvPollResult, + ): { geometry: WindowGeometry; focused: boolean; hwnd: number } | null { + if (result.matches.length === 0) return null; + + const focusedMatch = result.matches.find((m) => m.isForeground); + const best = + focusedMatch ?? + [...result.matches].sort((a, b) => b.area - a.area || b.bounds.width - a.bounds.width)[0]!; + + return { + geometry: best.bounds, + focused: best.isForeground, + hwnd: best.hwnd, + }; } private pollGeometry(): void { - if (this.pollInFlight || !this.helperSpec) { - return; - } - + if (this.pollInFlight) return; this.pollInFlight = true; - void this.runHelperWithSocketFallback() - .then(({ stdout, stderr }) => { - const geometry = parseWindowTrackerHelperOutput(stdout); - const focusState = parseWindowTrackerHelperFocusState(stderr); - this.updateTargetWindowFocused(focusState ?? Boolean(geometry)); - this.updateGeometry(geometry); - }) - .catch((error: unknown) => { - const err = error instanceof Error ? error : new Error(String(error)); - const stderr = - typeof error === 'object' && - error !== null && - 'stderr' in error && - typeof (error as { stderr?: unknown }).stderr === 'string' - ? (error as { stderr: string }).stderr - : ''; - this.maybeLogExecError(err, stderr); - this.updateGeometry(null); - }) - .finally(() => { - this.pollInFlight = false; - }); + + try { + const result = this.pollMpvWindows(); + const best = this.selectBestMatch(result); + + if (best) { + this.resetTrackingLossState(); + this.targetWindowMinimized = false; + this.currentTargetWindowHwnd = best.hwnd; + this.updateGeometry(best.geometry, best.focused); + this.updateTargetWindowFocused(best.focused); + return; + } + + if (result.windowState === 'minimized') { + this.targetWindowMinimized = true; + this.currentTargetWindowHwnd = null; + this.updateTargetWindowFocused(false); + this.registerTrackingMiss(this.minimizedTrackingLossGraceMs); + return; + } + + this.targetWindowMinimized = false; + this.currentTargetWindowHwnd = null; + this.updateTargetWindowFocused(false); + this.registerTrackingMiss(); + } catch (error: unknown) { + const err = error instanceof Error ? error : new Error(String(error)); + this.maybeLogPollError(err); + this.targetWindowMinimized = false; + this.currentTargetWindowHwnd = null; + this.updateTargetWindowFocused(false); + this.registerTrackingMiss(); + } finally { + this.pollInFlight = false; + } } }