mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
Compare commits
14 Commits
v0.11.2
...
v0.12.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
| f79e6bde3b | |||
| bd5275fbf8 | |||
| 3281a7b39e | |||
| 3e7573c9fc | |||
| 20a0efe572 | |||
| 7698258f61 | |||
| ac25213255 | |||
| a5dbe055fc | |||
| 04742b1806 | |||
| f0e15c5dc4 | |||
| 9145c730b5 | |||
| cf86817cd8 | |||
| 3f7de73734 | |||
| de9b887798 |
389
.github/workflows/prerelease.yml
vendored
Normal file
389
.github/workflows/prerelease.yml
vendored
Normal file
@@ -0,0 +1,389 @@
|
||||
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 }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-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: 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 }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-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
|
||||
|
||||
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 }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-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
|
||||
|
||||
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 }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-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 }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-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
|
||||
sha256sum "${files[@]}" > release/SHA256SUMS.txt
|
||||
|
||||
- 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
|
||||
|
||||
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
|
||||
gh release edit "${{ steps.version.outputs.VERSION }}" \
|
||||
--draft=false \
|
||||
--prerelease \
|
||||
--title "${{ steps.version.outputs.VERSION }}" \
|
||||
--notes-file release/prerelease-notes.md
|
||||
else
|
||||
gh release create "${{ steps.version.outputs.VERSION }}" \
|
||||
--latest=false \
|
||||
--prerelease \
|
||||
--title "${{ steps.version.outputs.VERSION }}" \
|
||||
--notes-file release/prerelease-notes.md
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
for asset in "${artifacts[@]}"; do
|
||||
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
|
||||
done
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-beta.*'
|
||||
- '!v*-rc.*'
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
@@ -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
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #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
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
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.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
3
bun.lock
3
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=="],
|
||||
|
||||
5
changes/2026-04-09-prerelease-workflow.md
Normal file
5
changes/2026-04-09-prerelease-workflow.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
4
changes/fix-overlay-subtitle-drop-routing.md
Normal file
4
changes/fix-overlay-subtitle-drop-routing.md
Normal file
@@ -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.
|
||||
11
changes/fix-windows-overlay-z-order.md
Normal file
11
changes/fix-windows-overlay-z-order.md
Normal file
@@ -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.
|
||||
4
changes/fix-windows-secondary-hover-titlebar.md
Normal file
4
changes/fix-windows-secondary-hover-titlebar.md
Normal file
@@ -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.
|
||||
4
changes/fix-yomitan-nested-popup-focus.md
Normal file
4
changes/fix-yomitan-nested-popup-focus.md
Normal file
@@ -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.
|
||||
@@ -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<version>`.
|
||||
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 <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<version>`.
|
||||
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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.11.2",
|
||||
"version": "0.12.0-beta.2",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<FragmentType, string> = {
|
||||
@@ -75,6 +76,10 @@ function resolveVersion(options: Pick<ChangelogOptions, 'cwd' | 'version' | 'dep
|
||||
return normalizeVersion(options.version ?? resolvePackageVersion(cwd, readFileSync));
|
||||
}
|
||||
|
||||
function isSupportedPrereleaseVersion(version: string): boolean {
|
||||
return /^\d+\.\d+\.\d+-(beta|rc)\.\d+$/u.test(normalizeVersion(version));
|
||||
}
|
||||
|
||||
function verifyRequestedVersionMatchesPackageVersion(
|
||||
options: Pick<ChangelogOptions, 'cwd' | 'version' | 'deps'>,
|
||||
): 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;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
param(
|
||||
[ValidateSet('geometry')]
|
||||
[ValidateSet('geometry', 'foreground-process', 'bind-overlay', 'lower-overlay', 'set-owner', 'clear-owner')]
|
||||
[string]$Mode = 'geometry',
|
||||
[string]$SocketPath
|
||||
[string]$SocketPath,
|
||||
[string]$OverlayWindowHandle
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -35,19 +36,89 @@ public static class SubMinerWindowsHelper {
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool SetWindowPos(
|
||||
IntPtr hWnd,
|
||||
IntPtr hWndInsertAfter,
|
||||
int X,
|
||||
int Y,
|
||||
int cx,
|
||||
int cy,
|
||||
uint uFlags
|
||||
);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern IntPtr SetWindowLongPtr(IntPtr hWnd, int nIndex, IntPtr dwNewLong);
|
||||
|
||||
[DllImport("dwmapi.dll")]
|
||||
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
|
||||
}
|
||||
"@
|
||||
|
||||
$DWMWA_EXTENDED_FRAME_BOUNDS = 9
|
||||
$SWP_NOSIZE = 0x0001
|
||||
$SWP_NOMOVE = 0x0002
|
||||
$SWP_NOACTIVATE = 0x0010
|
||||
$SWP_NOOWNERZORDER = 0x0200
|
||||
$SWP_FLAGS = $SWP_NOSIZE -bor $SWP_NOMOVE -bor $SWP_NOACTIVATE -bor $SWP_NOOWNERZORDER
|
||||
$GWL_EXSTYLE = -20
|
||||
$WS_EX_TOPMOST = 0x00000008
|
||||
$GWLP_HWNDPARENT = -8
|
||||
$HWND_TOP = [IntPtr]::Zero
|
||||
$HWND_BOTTOM = [IntPtr]::One
|
||||
$HWND_TOPMOST = [IntPtr](-1)
|
||||
$HWND_NOTOPMOST = [IntPtr](-2)
|
||||
|
||||
if ($Mode -eq 'foreground-process') {
|
||||
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
|
||||
if ($foregroundWindow -eq [IntPtr]::Zero) {
|
||||
Write-Output 'not-found'
|
||||
exit 0
|
||||
}
|
||||
|
||||
[uint32]$foregroundProcessId = 0
|
||||
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($foregroundWindow, [ref]$foregroundProcessId)
|
||||
if ($foregroundProcessId -eq 0) {
|
||||
Write-Output 'not-found'
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
$foregroundProcess = Get-Process -Id $foregroundProcessId -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Output 'not-found'
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Output "process=$($foregroundProcess.ProcessName)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Mode -eq 'clear-owner') {
|
||||
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
|
||||
[Console]::Error.WriteLine('overlay-window-handle-required')
|
||||
exit 1
|
||||
}
|
||||
|
||||
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
|
||||
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, [IntPtr]::Zero)
|
||||
Write-Output 'ok'
|
||||
exit 0
|
||||
}
|
||||
|
||||
function Get-WindowBounds {
|
||||
param([IntPtr]$hWnd)
|
||||
@@ -90,6 +161,7 @@ public static class SubMinerWindowsHelper {
|
||||
}
|
||||
|
||||
$mpvMatches = New-Object System.Collections.Generic.List[object]
|
||||
$targetWindowState = 'not-found'
|
||||
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
|
||||
$callback = [SubMinerWindowsHelper+EnumWindowsProc]{
|
||||
param([IntPtr]$hWnd, [IntPtr]$lParam)
|
||||
@@ -98,10 +170,6 @@ public static class SubMinerWindowsHelper {
|
||||
return $true
|
||||
}
|
||||
|
||||
if ([SubMinerWindowsHelper]::IsIconic($hWnd)) {
|
||||
return $true
|
||||
}
|
||||
|
||||
[uint32]$windowProcessId = 0
|
||||
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId)
|
||||
if ($windowProcessId -eq 0) {
|
||||
@@ -131,11 +199,22 @@ public static class SubMinerWindowsHelper {
|
||||
}
|
||||
}
|
||||
|
||||
if ([SubMinerWindowsHelper]::IsIconic($hWnd)) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($SocketPath) -and $targetWindowState -ne 'visible') {
|
||||
$targetWindowState = 'minimized'
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
$bounds = Get-WindowBounds -hWnd $hWnd
|
||||
if ($null -eq $bounds) {
|
||||
return $true
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
|
||||
$targetWindowState = 'visible'
|
||||
}
|
||||
|
||||
$mpvMatches.Add([PSCustomObject]@{
|
||||
HWnd = $hWnd
|
||||
X = $bounds.X
|
||||
@@ -151,12 +230,45 @@ public static class SubMinerWindowsHelper {
|
||||
|
||||
[void][SubMinerWindowsHelper]::EnumWindows($callback, [IntPtr]::Zero)
|
||||
|
||||
if ($Mode -eq 'lower-overlay') {
|
||||
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
|
||||
[Console]::Error.WriteLine('overlay-window-handle-required')
|
||||
exit 1
|
||||
}
|
||||
|
||||
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
|
||||
|
||||
[void][SubMinerWindowsHelper]::SetWindowPos(
|
||||
$overlayWindow,
|
||||
$HWND_NOTOPMOST,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$SWP_FLAGS
|
||||
)
|
||||
[void][SubMinerWindowsHelper]::SetWindowPos(
|
||||
$overlayWindow,
|
||||
$HWND_BOTTOM,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$SWP_FLAGS
|
||||
)
|
||||
Write-Output 'ok'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$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 (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
|
||||
[Console]::Error.WriteLine("state=$targetWindowState")
|
||||
}
|
||||
|
||||
if ($mpvMatches.Count -eq 0) {
|
||||
Write-Output 'not-found'
|
||||
@@ -168,6 +280,68 @@ public static class SubMinerWindowsHelper {
|
||||
} else {
|
||||
$mpvMatches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1
|
||||
}
|
||||
|
||||
if ($Mode -eq 'set-owner') {
|
||||
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
|
||||
[Console]::Error.WriteLine('overlay-window-handle-required')
|
||||
exit 1
|
||||
}
|
||||
|
||||
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
|
||||
$targetWindow = [IntPtr]$bestMatch.HWnd
|
||||
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
|
||||
Write-Output 'ok'
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Mode -eq 'bind-overlay') {
|
||||
if ([string]::IsNullOrWhiteSpace($OverlayWindowHandle)) {
|
||||
[Console]::Error.WriteLine('overlay-window-handle-required')
|
||||
exit 1
|
||||
}
|
||||
|
||||
[IntPtr]$overlayWindow = [IntPtr]([int64]$OverlayWindowHandle)
|
||||
$targetWindow = [IntPtr]$bestMatch.HWnd
|
||||
[void][SubMinerWindowsHelper]::SetWindowLongPtr($overlayWindow, $GWLP_HWNDPARENT, $targetWindow)
|
||||
$targetWindowExStyle = [SubMinerWindowsHelper]::GetWindowLong($targetWindow, $GWL_EXSTYLE)
|
||||
$targetWindowIsTopmost = ($targetWindowExStyle -band $WS_EX_TOPMOST) -ne 0
|
||||
|
||||
$overlayExStyle = [SubMinerWindowsHelper]::GetWindowLong($overlayWindow, $GWL_EXSTYLE)
|
||||
$overlayIsTopmost = ($overlayExStyle -band $WS_EX_TOPMOST) -ne 0
|
||||
if ($targetWindowIsTopmost -and -not $overlayIsTopmost) {
|
||||
[void][SubMinerWindowsHelper]::SetWindowPos(
|
||||
$overlayWindow, $HWND_TOPMOST, 0, 0, 0, 0, $SWP_FLAGS
|
||||
)
|
||||
} elseif (-not $targetWindowIsTopmost -and $overlayIsTopmost) {
|
||||
[void][SubMinerWindowsHelper]::SetWindowPos(
|
||||
$overlayWindow, $HWND_NOTOPMOST, 0, 0, 0, 0, $SWP_FLAGS
|
||||
)
|
||||
}
|
||||
|
||||
$GW_HWNDPREV = 3
|
||||
$windowAboveMpv = [SubMinerWindowsHelper]::GetWindow($targetWindow, $GW_HWNDPREV)
|
||||
|
||||
if ($windowAboveMpv -ne [IntPtr]::Zero -and $windowAboveMpv -eq $overlayWindow) {
|
||||
Write-Output 'ok'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$insertAfter = $HWND_TOP
|
||||
if ($windowAboveMpv -ne [IntPtr]::Zero) {
|
||||
$aboveExStyle = [SubMinerWindowsHelper]::GetWindowLong($windowAboveMpv, $GWL_EXSTYLE)
|
||||
$aboveIsTopmost = ($aboveExStyle -band $WS_EX_TOPMOST) -ne 0
|
||||
if ($aboveIsTopmost -eq $targetWindowIsTopmost) {
|
||||
$insertAfter = $windowAboveMpv
|
||||
}
|
||||
}
|
||||
|
||||
[void][SubMinerWindowsHelper]::SetWindowPos(
|
||||
$overlayWindow, $insertAfter, 0, 0, 0, 0, $SWP_FLAGS
|
||||
)
|
||||
Write-Output 'ok'
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Output "$($bestMatch.X),$($bestMatch.Y),$($bestMatch.Width),$($bestMatch.Height)"
|
||||
} catch {
|
||||
[Console]::Error.WriteLine($_.Exception.Message)
|
||||
|
||||
@@ -72,6 +72,7 @@ export {
|
||||
createOverlayWindow,
|
||||
enforceOverlayLayerOrder,
|
||||
ensureOverlayWindowLevel,
|
||||
isOverlayWindowContentReady,
|
||||
syncOverlayWindowLayer,
|
||||
updateOverlayWindowBounds,
|
||||
} from './overlay-window';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,23 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { initializeOverlayAnkiIntegration, initializeOverlayRuntime } from './overlay-runtime-init';
|
||||
|
||||
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('initializeOverlayRuntime skips Anki integration when ankiConnect.enabled is false', () => {
|
||||
let createdIntegrations = 0;
|
||||
let startedIntegrations = 0;
|
||||
@@ -443,3 +460,216 @@ 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 preserves visible overlay on Windows tracker loss when target is not minimized', () => {
|
||||
withPlatform('win32', () => {
|
||||
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, ['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);
|
||||
});
|
||||
|
||||
@@ -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,23 @@ export function initializeOverlayRuntime(options: {
|
||||
};
|
||||
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
options.bindOverlayOwner?.();
|
||||
if (options.isVisibleOverlayVisible()) {
|
||||
options.updateVisibleOverlayVisibility();
|
||||
options.refreshCurrentSubtitle?.();
|
||||
}
|
||||
};
|
||||
windowTracker.onWindowLost = () => {
|
||||
options.releaseOverlayOwner?.();
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
typeof windowTracker.isTargetWindowMinimized === 'function' &&
|
||||
!windowTracker.isTargetWindowMinimized()
|
||||
) {
|
||||
options.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const window of options.getOverlayWindows()) {
|
||||
window.hide();
|
||||
}
|
||||
|
||||
@@ -6,27 +6,59 @@ import { setVisibleOverlayVisible, updateVisibleOverlayVisibility } from './over
|
||||
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;
|
||||
const window = {
|
||||
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');
|
||||
},
|
||||
};
|
||||
|
||||
return { window, calls };
|
||||
return {
|
||||
window,
|
||||
calls,
|
||||
getOpacity: () => opacity,
|
||||
setFocused: (nextFocused: boolean) => {
|
||||
focused = nextFocused;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('macOS keeps visible overlay hidden while tracker is not ready and emits one loading OSD', () => {
|
||||
@@ -163,7 +195,286 @@ 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<void>((resolve) => setTimeout(resolve, 60));
|
||||
assert.equal(getOpacity(), 1);
|
||||
assert.equal(syncWindowsZOrderCalls, 2);
|
||||
assert.ok(calls.includes('opacity:1'));
|
||||
});
|
||||
|
||||
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 +502,209 @@ 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('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
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 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 +862,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;
|
||||
|
||||
@@ -2,16 +2,67 @@ import type { BrowserWindow } from 'electron';
|
||||
import { BaseWindowTracker } from '../../window-trackers';
|
||||
import { WindowGeometry } from '../../types';
|
||||
|
||||
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
|
||||
const pendingWindowsOverlayRevealTimeoutByWindow = new WeakMap<
|
||||
BrowserWindow,
|
||||
ReturnType<typeof setTimeout>
|
||||
>();
|
||||
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
|
||||
|
||||
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;
|
||||
modalActive?: boolean;
|
||||
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 +81,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 +92,92 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
|
||||
const showPassiveVisibleOverlay = (): void => {
|
||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||
if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) {
|
||||
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 shouldIgnoreMouseEvents =
|
||||
forceMousePassthrough || (shouldDefaultToPassthrough && !isVisibleOverlayFocused);
|
||||
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
||||
const shouldKeepTrackedWindowsOverlayTopmost =
|
||||
!args.isWindowsPlatform ||
|
||||
!args.windowTracker ||
|
||||
isVisibleOverlayFocused ||
|
||||
isTrackedWindowsTargetFocused ||
|
||||
shouldPreserveWindowsOverlayDuringFocusHandoff ||
|
||||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
|
||||
const wasVisible = mainWindow.isVisible();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,32 @@ 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 originalPlatform = 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 {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('overlay window config uses the provided Yomitan session when available', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -13,6 +13,17 @@ import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds
|
||||
|
||||
const logger = createLogger('main:overlay-window');
|
||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||
const overlayWindowContentReady = new WeakSet<BrowserWindow>();
|
||||
const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
|
||||
|
||||
export function isOverlayWindowContentReady(window: BrowserWindow): boolean {
|
||||
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 +87,17 @@ 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));
|
||||
|
||||
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 +108,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 +159,8 @@ export function createOverlayWindow(
|
||||
moveWindowTop: () => {
|
||||
window.moveTop();
|
||||
},
|
||||
onWindowsVisibleOverlayBlur:
|
||||
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
285
src/main.ts
285
src/main.ts
@@ -130,6 +130,15 @@ import {
|
||||
type LogLevelSource,
|
||||
} from './logger';
|
||||
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
|
||||
import {
|
||||
bindWindowsOverlayAboveMpvNative,
|
||||
clearWindowsOverlayOwnerNative,
|
||||
ensureWindowsOverlayTransparencyNative,
|
||||
getWindowsForegroundProcessNameNative,
|
||||
queryWindowsForegroundProcessName,
|
||||
setWindowsOverlayOwnerNative,
|
||||
syncWindowsOverlayToMpvZOrder,
|
||||
} from './window-trackers/windows-helper';
|
||||
import {
|
||||
commandNeedsOverlayStartupPrereqs,
|
||||
commandNeedsOverlayRuntime,
|
||||
@@ -1835,6 +1844,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 +1855,9 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
ensureOverlayWindowLevel: (window) => {
|
||||
ensureOverlayWindowLevel(window);
|
||||
},
|
||||
syncWindowsOverlayToMpvZOrder: (_window) => {
|
||||
requestWindowsVisibleOverlayZOrderSync();
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: (layer) => {
|
||||
syncPrimaryOverlayWindowLayer(layer);
|
||||
},
|
||||
@@ -1870,6 +1885,223 @@ 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<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let windowsVisibleOverlayForegroundPollInFlight = false;
|
||||
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);
|
||||
}
|
||||
|
||||
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
|
||||
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);
|
||||
if (bindWindowsOverlayAboveMpvNative(overlayHwnd)) {
|
||||
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
const synced = await syncWindowsOverlayToMpvZOrder({
|
||||
overlayWindowHandle: getWindowsNativeWindowHandle(mainWindow),
|
||||
targetMpvSocketPath: appState.mpvSocketPath,
|
||||
});
|
||||
if (synced) {
|
||||
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
|
||||
}
|
||||
return synced;
|
||||
}
|
||||
|
||||
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 = getWindowsForegroundProcessNameNative();
|
||||
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 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,
|
||||
@@ -3674,6 +3906,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 +4034,14 @@ function createModalWindow(): BrowserWindow {
|
||||
}
|
||||
|
||||
function createMainWindow(): BrowserWindow {
|
||||
return createMainWindowHandler();
|
||||
const window = createMainWindowHandler();
|
||||
if (process.platform === 'win32') {
|
||||
const overlayHwnd = getWindowsNativeWindowHandleNumber(window);
|
||||
if (!ensureWindowsOverlayTransparencyNative(overlayHwnd)) {
|
||||
logger.warn('Failed to eagerly extend Windows overlay transparency via koffi');
|
||||
}
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
function ensureTray(): void {
|
||||
@@ -4595,6 +4840,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 +4943,9 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
updateVisibleOverlayVisibility: () =>
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
},
|
||||
refreshCurrentSubtitle: () => {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
},
|
||||
overlayShortcutsRuntime: {
|
||||
syncOverlayShortcuts: () => overlayShortcutsRuntime.syncOverlayShortcuts(),
|
||||
},
|
||||
@@ -4719,6 +4969,39 @@ 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);
|
||||
if (bindWindowsOverlayAboveMpvNative(overlayHwnd)) {
|
||||
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 (!setWindowsOverlayOwnerNative(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 (!clearWindowsOverlayOwnerNative(overlayHwnd)) {
|
||||
logger.warn('Failed to clear overlay owner via koffi');
|
||||
}
|
||||
},
|
||||
getOverlayWindows: () => getOverlayWindows(),
|
||||
getResolvedConfig: () => getResolvedConfig(),
|
||||
showDesktopNotification,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -31,6 +31,8 @@ type InitializeOverlayRuntimeCore = (options: {
|
||||
) => Promise<KikuFieldGroupingChoice>;
|
||||
getKnownWordCacheStatePath: () => string;
|
||||
shouldStartAnkiIntegration: () => boolean;
|
||||
bindOverlayOwner?: () => void;
|
||||
releaseOverlayOwner?: () => void;
|
||||
}) => void;
|
||||
|
||||
export function createInitializeOverlayRuntimeHandler(deps: {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<KikuFieldGroupingChoice>;
|
||||
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<KikuFieldGroupingChoice>;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -11,6 +11,8 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(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<TWindow>(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<TWindow>(deps: {
|
||||
isOverlayVisible: deps.isOverlayVisible,
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
forwardTabToMpv: deps.forwardTabToMpv,
|
||||
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||
onWindowContentReady: deps.onWindowContentReady,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ export function createCreateOverlayWindowHandler<TWindow>(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<TWindow>(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<TWindow>(deps: {
|
||||
isOverlayVisible: deps.isOverlayVisible,
|
||||
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
|
||||
forwardTabToMpv: deps.forwardTabToMpv,
|
||||
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
|
||||
onWindowContentReady: deps.onWindowContentReady,
|
||||
onWindowClosed: deps.onWindowClosed,
|
||||
yomitanSession: deps.getYomitanSession?.() ?? null,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
|
||||
) {
|
||||
return (): UpdateVisibleOverlayBoundsMainDeps => ({
|
||||
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
|
||||
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
61
src/prerelease-workflow.test.ts
Normal file
61
src/prerelease-workflow.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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');
|
||||
const packageJsonPath = resolve(__dirname, '../package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
|
||||
test('prerelease workflow triggers on beta and rc tags only', () => {
|
||||
assert.match(prereleaseWorkflow, /name: Prerelease/);
|
||||
assert.match(prereleaseWorkflow, /tags:\s*\n\s*-\s*'v\*-beta\.\*'/);
|
||||
assert.match(prereleaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'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 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 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 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/);
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import type { Keybinding, ShortcutsConfig } from '../../types';
|
||||
import type { RendererContext } from '../context';
|
||||
import {
|
||||
YOMITAN_POPUP_HOST_SELECTOR,
|
||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||
YOMITAN_POPUP_SHOWN_EVENT,
|
||||
YOMITAN_POPUP_COMMAND_EVENT,
|
||||
@@ -61,6 +62,9 @@ export function createKeyboardHandlers(
|
||||
if (target.closest('.modal')) return true;
|
||||
if (ctx.dom.subtitleContainer.contains(target)) return true;
|
||||
if (isYomitanPopupIframe(target)) return true;
|
||||
if (target.closest && target.closest(YOMITAN_POPUP_HOST_SELECTOR)) {
|
||||
return true;
|
||||
}
|
||||
if (target.closest && target.closest('iframe.yomitan-popup, iframe[id^="yomitan-popup"]'))
|
||||
return true;
|
||||
return false;
|
||||
|
||||
@@ -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<string>();
|
||||
@@ -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<string, Array<() => 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<string, Array<() => 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<string, Array<(event: unknown) => 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<string, Array<(event: unknown) => 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<string, Array<() => void>>();
|
||||
const documentListeners = new Map<string, Array<(event: unknown) => 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<string, Array<(event: unknown) => 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 });
|
||||
|
||||
@@ -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<void> {
|
||||
async function handleMouseEnter(
|
||||
_event?: MouseEvent,
|
||||
showSecondaryHover = false,
|
||||
source: 'direct' | 'tracked-pointer' = 'direct',
|
||||
): Promise<void> {
|
||||
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) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -527,6 +529,12 @@ async function init(): Promise<void> {
|
||||
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', () => {
|
||||
@@ -654,10 +662,6 @@ async function init(): Promise<void> {
|
||||
);
|
||||
measurementReporter.schedule();
|
||||
|
||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
}
|
||||
|
||||
measurementReporter.emitNow();
|
||||
}
|
||||
|
||||
@@ -706,18 +710,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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;/);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -29,21 +33,56 @@ export function isYomitanPopupIframe(element: Element | null): boolean {
|
||||
}
|
||||
|
||||
export function hasYomitanPopupIframe(root: ParentNode = document): boolean {
|
||||
return root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null;
|
||||
return (
|
||||
typeof root.querySelector === 'function' &&
|
||||
(root.querySelector(YOMITAN_POPUP_IFRAME_SELECTOR) !== null ||
|
||||
root.querySelector(YOMITAN_POPUP_HOST_SELECTOR) !== null)
|
||||
);
|
||||
}
|
||||
|
||||
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<T extends Element>(root: ParentNode, selector: string): T[] {
|
||||
if (typeof root.querySelectorAll !== 'function') {
|
||||
return [];
|
||||
}
|
||||
return Array.from(root.querySelectorAll<T>(selector));
|
||||
}
|
||||
|
||||
export function isYomitanPopupVisible(root: ParentNode = document): boolean {
|
||||
const popupIframes = root.querySelectorAll<HTMLIFrameElement>(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;
|
||||
}
|
||||
const visiblePopupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_VISIBLE_HOST_SELECTOR);
|
||||
if (visiblePopupHosts.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const popupIframes = queryPopupElements<HTMLIFrameElement>(root, YOMITAN_POPUP_IFRAME_SELECTOR);
|
||||
for (const iframe of popupIframes) {
|
||||
if (isVisiblePopupElement(iframe)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const popupHosts = queryPopupElements<HTMLElement>(root, YOMITAN_POPUP_HOST_SELECTOR);
|
||||
for (const host of popupHosts) {
|
||||
if (isMarkedVisiblePopupHost(host)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
250
src/window-trackers/win32.ts
Normal file
250
src/window-trackers/win32.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import koffi from 'koffi';
|
||||
|
||||
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 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 {
|
||||
DwmExtendFrameIntoClientArea(overlayHwnd, {
|
||||
cxLeftWidth: -1,
|
||||
cxRightWidth: -1,
|
||||
cyTopHeight: -1,
|
||||
cyBottomHeight: -1,
|
||||
});
|
||||
}
|
||||
|
||||
export interface WindowBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface MpvWindowMatch {
|
||||
hwnd: number;
|
||||
bounds: WindowBounds;
|
||||
area: number;
|
||||
isForeground: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export function findMpvWindows(): MpvPollResult {
|
||||
const foregroundHwnd = GetForegroundWindow();
|
||||
const matches: MpvWindowMatch[] = [];
|
||||
let hasMinimized = false;
|
||||
let hasFocused = false;
|
||||
const processNameCache = new Map<number, string | null>();
|
||||
|
||||
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;
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 {
|
||||
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
|
||||
extendOverlayFrameIntoClientArea(overlayHwnd);
|
||||
}
|
||||
|
||||
export function ensureOverlayTransparency(overlayHwnd: number): void {
|
||||
extendOverlayFrameIntoClientArea(overlayHwnd);
|
||||
}
|
||||
|
||||
export function clearOverlayOwner(overlayHwnd: number): void {
|
||||
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, 0);
|
||||
}
|
||||
|
||||
export function bindOverlayAboveMpv(overlayHwnd: number, mpvHwnd: number): void {
|
||||
SetWindowLongPtrW(overlayHwnd, GWLP_HWNDPARENT, mpvHwnd);
|
||||
const mpvExStyle = GetWindowLongW(mpvHwnd, GWL_EXSTYLE);
|
||||
const mpvIsTopmost = (mpvExStyle & WS_EX_TOPMOST) !== 0;
|
||||
|
||||
const overlayExStyle = GetWindowLongW(overlayHwnd, GWL_EXSTYLE);
|
||||
const overlayIsTopmost = (overlayExStyle & WS_EX_TOPMOST) !== 0;
|
||||
|
||||
if (mpvIsTopmost && !overlayIsTopmost) {
|
||||
SetWindowPos(overlayHwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_FLAGS);
|
||||
} else if (!mpvIsTopmost && overlayIsTopmost) {
|
||||
SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS);
|
||||
}
|
||||
|
||||
const windowAboveMpv = GetWindowFn(mpvHwnd, GW_HWNDPREV);
|
||||
if (windowAboveMpv !== 0 && windowAboveMpv === overlayHwnd) return;
|
||||
|
||||
let insertAfter = HWND_TOP;
|
||||
if (windowAboveMpv !== 0) {
|
||||
const aboveExStyle = GetWindowLongW(windowAboveMpv, GWL_EXSTYLE);
|
||||
const aboveIsTopmost = (aboveExStyle & WS_EX_TOPMOST) !== 0;
|
||||
if (aboveIsTopmost === mpvIsTopmost) {
|
||||
insertAfter = windowAboveMpv;
|
||||
}
|
||||
}
|
||||
|
||||
SetWindowPos(overlayHwnd, insertAfter, 0, 0, 0, 0, SWP_FLAGS);
|
||||
}
|
||||
|
||||
export function lowerOverlay(overlayHwnd: number): void {
|
||||
SetWindowPos(overlayHwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_FLAGS);
|
||||
SetWindowPos(overlayHwnd, HWND_BOTTOM, 0, 0, 0, 0, SWP_FLAGS);
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
lowerWindowsOverlayInZOrder,
|
||||
parseWindowTrackerHelperForegroundProcess,
|
||||
parseWindowTrackerHelperFocusState,
|
||||
parseWindowTrackerHelperOutput,
|
||||
parseWindowTrackerHelperState,
|
||||
queryWindowsForegroundProcessName,
|
||||
resolveWindowsTrackerHelper,
|
||||
syncWindowsOverlayToMpvZOrder,
|
||||
} from './windows-helper';
|
||||
|
||||
test('parseWindowTrackerHelperOutput parses helper geometry output', () => {
|
||||
@@ -28,6 +33,105 @@ test('parseWindowTrackerHelperFocusState parses helper stderr metadata', () => {
|
||||
assert.equal(parseWindowTrackerHelperFocusState(''), null);
|
||||
});
|
||||
|
||||
test('parseWindowTrackerHelperState parses helper stderr metadata', () => {
|
||||
assert.equal(parseWindowTrackerHelperState('state=visible'), 'visible');
|
||||
assert.equal(parseWindowTrackerHelperState('focus=not-focused\nstate=minimized'), 'minimized');
|
||||
assert.equal(parseWindowTrackerHelperState('state=unknown'), null);
|
||||
assert.equal(parseWindowTrackerHelperState(''), null);
|
||||
});
|
||||
|
||||
test('parseWindowTrackerHelperForegroundProcess parses helper stdout metadata', () => {
|
||||
assert.equal(parseWindowTrackerHelperForegroundProcess('process=mpv'), 'mpv');
|
||||
assert.equal(parseWindowTrackerHelperForegroundProcess('process=chrome'), 'chrome');
|
||||
assert.equal(parseWindowTrackerHelperForegroundProcess('not-found'), null);
|
||||
assert.equal(parseWindowTrackerHelperForegroundProcess(''), null);
|
||||
});
|
||||
|
||||
test('queryWindowsForegroundProcessName reads foreground process from powershell helper', async () => {
|
||||
const processName = await queryWindowsForegroundProcessName({
|
||||
resolveHelper: () => ({
|
||||
kind: 'powershell',
|
||||
command: 'powershell.exe',
|
||||
args: ['-File', 'helper.ps1'],
|
||||
helperPath: 'helper.ps1',
|
||||
}),
|
||||
runHelper: async () => ({
|
||||
stdout: 'process=mpv',
|
||||
stderr: '',
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(processName, 'mpv');
|
||||
});
|
||||
|
||||
test('queryWindowsForegroundProcessName returns null when no powershell helper is available', async () => {
|
||||
const processName = await queryWindowsForegroundProcessName({
|
||||
resolveHelper: () => ({
|
||||
kind: 'native',
|
||||
command: 'helper.exe',
|
||||
args: [],
|
||||
helperPath: 'helper.exe',
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(processName, null);
|
||||
});
|
||||
|
||||
test('syncWindowsOverlayToMpvZOrder forwards socket path and overlay handle to powershell helper', async () => {
|
||||
let capturedMode: string | null = null;
|
||||
let capturedArgs: string[] | null = null;
|
||||
|
||||
const synced = await syncWindowsOverlayToMpvZOrder({
|
||||
overlayWindowHandle: '12345',
|
||||
targetMpvSocketPath: '\\\\.\\pipe\\subminer-socket',
|
||||
resolveHelper: () => ({
|
||||
kind: 'powershell',
|
||||
command: 'powershell.exe',
|
||||
args: ['-File', 'helper.ps1'],
|
||||
helperPath: 'helper.ps1',
|
||||
}),
|
||||
runHelper: async (_spec, mode, extraArgs = []) => {
|
||||
capturedMode = mode;
|
||||
capturedArgs = extraArgs;
|
||||
return {
|
||||
stdout: 'ok',
|
||||
stderr: '',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(synced, true);
|
||||
assert.equal(capturedMode, 'bind-overlay');
|
||||
assert.deepEqual(capturedArgs, ['\\\\.\\pipe\\subminer-socket', '12345']);
|
||||
});
|
||||
|
||||
test('lowerWindowsOverlayInZOrder forwards overlay handle to powershell helper', async () => {
|
||||
let capturedMode: string | null = null;
|
||||
let capturedArgs: string[] | null = null;
|
||||
|
||||
const lowered = await lowerWindowsOverlayInZOrder({
|
||||
overlayWindowHandle: '67890',
|
||||
resolveHelper: () => ({
|
||||
kind: 'powershell',
|
||||
command: 'powershell.exe',
|
||||
args: ['-File', 'helper.ps1'],
|
||||
helperPath: 'helper.ps1',
|
||||
}),
|
||||
runHelper: async (_spec, mode, extraArgs = []) => {
|
||||
capturedMode = mode;
|
||||
capturedArgs = extraArgs;
|
||||
return {
|
||||
stdout: 'ok',
|
||||
stderr: '',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(lowered, true);
|
||||
assert.equal(capturedMode, 'lower-overlay');
|
||||
assert.deepEqual(capturedArgs, ['67890']);
|
||||
});
|
||||
|
||||
test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => {
|
||||
const helper = resolveWindowsTrackerHelper({
|
||||
dirname: 'C:\\repo\\dist\\window-trackers',
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { execFile, type ExecFileException } from 'child_process';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import { createLogger } from '../logger';
|
||||
|
||||
@@ -26,6 +27,13 @@ const log = createLogger('tracker').child('windows-helper');
|
||||
|
||||
export type WindowsTrackerHelperKind = 'powershell' | 'native';
|
||||
export type WindowsTrackerHelperMode = 'auto' | 'powershell' | 'native';
|
||||
export type WindowsTrackerHelperRunMode =
|
||||
| 'geometry'
|
||||
| 'foreground-process'
|
||||
| 'bind-overlay'
|
||||
| 'lower-overlay'
|
||||
| 'set-owner'
|
||||
| 'clear-owner';
|
||||
|
||||
export type WindowsTrackerHelperLaunchSpec = {
|
||||
kind: WindowsTrackerHelperKind;
|
||||
@@ -219,6 +227,197 @@ export function parseWindowTrackerHelperFocusState(output: string): boolean | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseWindowTrackerHelperState(output: string): 'visible' | 'minimized' | null {
|
||||
const stateLine = output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('state='));
|
||||
|
||||
if (!stateLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = stateLine.slice('state='.length).trim().toLowerCase();
|
||||
if (value === 'visible') {
|
||||
return 'visible';
|
||||
}
|
||||
if (value === 'minimized') {
|
||||
return 'minimized';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseWindowTrackerHelperForegroundProcess(output: string): string | null {
|
||||
const processLine = output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('process='));
|
||||
|
||||
if (!processLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = processLine.slice('process='.length).trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
type WindowsTrackerHelperRunnerResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
function runWindowsTrackerHelperWithExecFile(
|
||||
spec: WindowsTrackerHelperLaunchSpec,
|
||||
mode: WindowsTrackerHelperRunMode,
|
||||
extraArgs: string[] = [],
|
||||
): Promise<WindowsTrackerHelperRunnerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const modeArgs = spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode];
|
||||
execFile(
|
||||
spec.command,
|
||||
[...spec.args, ...modeArgs, ...extraArgs],
|
||||
{
|
||||
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 });
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function queryWindowsForegroundProcessName(deps: {
|
||||
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
|
||||
runHelper?: (
|
||||
spec: WindowsTrackerHelperLaunchSpec,
|
||||
mode: WindowsTrackerHelperRunMode,
|
||||
extraArgs?: string[],
|
||||
) => Promise<WindowsTrackerHelperRunnerResult>;
|
||||
} = {}): Promise<string | null> {
|
||||
const spec =
|
||||
deps.resolveHelper?.() ??
|
||||
resolveWindowsTrackerHelper({
|
||||
helperModeEnv: 'powershell',
|
||||
});
|
||||
if (!spec || spec.kind !== 'powershell') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
|
||||
const { stdout } = await runHelper(spec, 'foreground-process');
|
||||
return parseWindowTrackerHelperForegroundProcess(stdout);
|
||||
}
|
||||
|
||||
export async function syncWindowsOverlayToMpvZOrder(deps: {
|
||||
overlayWindowHandle: string;
|
||||
targetMpvSocketPath?: string | null;
|
||||
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
|
||||
runHelper?: (
|
||||
spec: WindowsTrackerHelperLaunchSpec,
|
||||
mode: WindowsTrackerHelperRunMode,
|
||||
extraArgs?: string[],
|
||||
) => Promise<WindowsTrackerHelperRunnerResult>;
|
||||
}): Promise<boolean> {
|
||||
const spec =
|
||||
deps.resolveHelper?.() ??
|
||||
resolveWindowsTrackerHelper({
|
||||
helperModeEnv: 'powershell',
|
||||
});
|
||||
if (!spec || spec.kind !== 'powershell') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
|
||||
const extraArgs = [deps.targetMpvSocketPath ?? '', deps.overlayWindowHandle];
|
||||
const { stdout } = await runHelper(spec, 'bind-overlay', extraArgs);
|
||||
return stdout.trim() === 'ok';
|
||||
}
|
||||
|
||||
export async function lowerWindowsOverlayInZOrder(deps: {
|
||||
overlayWindowHandle: string;
|
||||
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
|
||||
runHelper?: (
|
||||
spec: WindowsTrackerHelperLaunchSpec,
|
||||
mode: WindowsTrackerHelperRunMode,
|
||||
extraArgs?: string[],
|
||||
) => Promise<WindowsTrackerHelperRunnerResult>;
|
||||
}): Promise<boolean> {
|
||||
const spec =
|
||||
deps.resolveHelper?.() ??
|
||||
resolveWindowsTrackerHelper({
|
||||
helperModeEnv: 'powershell',
|
||||
});
|
||||
if (!spec || spec.kind !== 'powershell') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const runHelper = deps.runHelper ?? runWindowsTrackerHelperWithExecFile;
|
||||
const { stdout } = await runHelper(spec, 'lower-overlay', [deps.overlayWindowHandle]);
|
||||
return stdout.trim() === 'ok';
|
||||
}
|
||||
|
||||
export function setWindowsOverlayOwnerNative(overlayHwnd: number, mpvHwnd: number): boolean {
|
||||
try {
|
||||
const win32 = require('./win32') as typeof import('./win32');
|
||||
win32.setOverlayOwner(overlayHwnd, mpvHwnd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureWindowsOverlayTransparencyNative(overlayHwnd: number): boolean {
|
||||
try {
|
||||
const win32 = require('./win32') as typeof import('./win32');
|
||||
win32.ensureOverlayTransparency(overlayHwnd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function bindWindowsOverlayAboveMpvNative(overlayHwnd: number): boolean {
|
||||
try {
|
||||
const win32 = require('./win32') as typeof import('./win32');
|
||||
const poll = win32.findMpvWindows();
|
||||
const focused = poll.matches.find((m) => m.isForeground);
|
||||
const best = focused ?? poll.matches.sort((a, b) => b.area - a.area)[0];
|
||||
if (!best) return false;
|
||||
win32.bindOverlayAboveMpv(overlayHwnd, best.hwnd);
|
||||
win32.ensureOverlayTransparency(overlayHwnd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWindowsOverlayOwnerNative(overlayHwnd: number): boolean {
|
||||
try {
|
||||
const win32 = require('./win32') as typeof import('./win32');
|
||||
win32.clearOverlayOwner(overlayHwnd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getWindowsForegroundProcessNameNative(): string | null {
|
||||
try {
|
||||
const win32 = require('./win32') as typeof import('./win32');
|
||||
return win32.getForegroundProcessName();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWindowsTrackerHelper(
|
||||
options: ResolveWindowsTrackerHelperOptions = {},
|
||||
): WindowsTrackerHelperLaunchSpec | null {
|
||||
|
||||
@@ -1,56 +1,62 @@
|
||||
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<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
function mpvVisible(
|
||||
overrides: Partial<MpvPollResult & { x?: number; y?: number; width?: number; height?: number; focused?: boolean }> = {},
|
||||
): 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 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;
|
||||
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',
|
||||
};
|
||||
pollMpvWindows: () => {
|
||||
pollCalls += 1;
|
||||
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, 2);
|
||||
});
|
||||
|
||||
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 +67,180 @@ test('WindowsWindowTracker updates geometry from helper output', async () => {
|
||||
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker clears geometry for helper misses', async () => {
|
||||
test('WindowsWindowTracker clears geometry for poll misses', () => {
|
||||
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: () => 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<string | null> = [];
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -16,80 +16,50 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<WindowsTrackerRunnerResult>;
|
||||
pollMpvWindows?: () => MpvPollResult;
|
||||
maxConsecutiveMisses?: number;
|
||||
trackingLossGraceMs?: number;
|
||||
minimizedTrackingLossGraceMs?: number;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
function runHelperWithExecFile(
|
||||
spec: WindowsTrackerHelperLaunchSpec,
|
||||
mode: 'geometry',
|
||||
targetMpvSocketPath: string | null,
|
||||
): Promise<WindowsTrackerRunnerResult> {
|
||||
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(): MpvPollResult {
|
||||
const win32 = require('./win32') as typeof import('./win32');
|
||||
return win32.findMpvWindows();
|
||||
}
|
||||
|
||||
export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private pollInFlight = false;
|
||||
private helperSpec: WindowsTrackerHelperLaunchSpec | null;
|
||||
private readonly targetMpvSocketPath: string | null;
|
||||
private readonly runHelper: (
|
||||
spec: WindowsTrackerHelperLaunchSpec,
|
||||
mode: 'geometry',
|
||||
targetMpvSocketPath: string | null,
|
||||
) => Promise<WindowsTrackerRunnerResult>;
|
||||
private lastExecErrorFingerprint: string | null = null;
|
||||
private lastExecErrorLoggedAtMs = 0;
|
||||
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;
|
||||
|
||||
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.pollMpvWindows = deps.pollMpvWindows ?? defaultPollMpvWindows;
|
||||
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 +74,99 @@ 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<WindowsTrackerRunnerResult> {
|
||||
if (!this.helperSpec) {
|
||||
return { stdout: 'not-found', stderr: '' };
|
||||
}
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
this.lastPollErrorFingerprint = fingerprint;
|
||||
this.lastPollErrorLoggedAtMs = now;
|
||||
log.warn('Windows native poll failed', { error: error.message });
|
||||
}
|
||||
|
||||
return await this.runHelper(this.helperSpec, 'geometry', null);
|
||||
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 } | 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,
|
||||
};
|
||||
}
|
||||
|
||||
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.updateTargetWindowFocused(best.focused);
|
||||
this.updateGeometry(best.geometry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.windowState === 'minimized') {
|
||||
this.targetWindowMinimized = true;
|
||||
this.updateTargetWindowFocused(false);
|
||||
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.targetWindowMinimized = false;
|
||||
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.updateTargetWindowFocused(false);
|
||||
this.registerTrackingMiss();
|
||||
} finally {
|
||||
this.pollInFlight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
vendor/subminer-yomitan
vendored
2
vendor/subminer-yomitan
vendored
Submodule vendor/subminer-yomitan updated: 69620abcbc...ed31b7a3ee
Reference in New Issue
Block a user