Compare commits

..

16 Commits

Author SHA1 Message Date
sudacode 4d010e6a18 chore(release): prepare 0.15.0-beta.3 2026-05-16 20:58:12 -07:00
sudacode 5250ca8214 fix(controller): save remaps per profile, gate modals on enabled (#69) 2026-05-16 20:43:27 -07:00
sudacode 49f89e6452 Fix macOS overlay foreground handling and character-dictionary cache reuse (#68) 2026-05-16 20:43:01 -07:00
sudacode 89723e2ccb fix(updater): handle unsupported macOS app updates 2026-05-16 02:05:28 -07:00
sudacode d05e2bd8ec ci(prerelease): use committed release-notes, drop CI claude regen
CI runners don't have Claude Code installed, so the regen step in the
prerelease workflow died on 'claude CLI not found'. Mirror the stable
release model: generate prerelease-notes.md locally (claude -p), commit
it, and have CI just verify and reference the committed file.
2026-05-16 00:54:49 -07:00
sudacode 7484d3c102 ci(prerelease): keep Install Lua before test:fast for workflow assertion 2026-05-16 00:33:19 -07:00
sudacode f78a875ba3 ci(prerelease): install Lua before env suite 2026-05-16 00:23:39 -07:00
sudacode a025652542 chore(release): prepare v0.15.0-beta.1 2026-05-16 00:11:21 -07:00
sudacode 91a01b86a9 feat: add auto update support (#65) 2026-05-16 00:09:14 -07:00
sudacode 105713361e fix(macos): preserve overlay on transient tracker loss; fix subsync modal open (#66) 2026-05-15 20:46:00 -07:00
sudacode 4cb0dbfaad Remove vendor source-inspection tests from yomitan-settings (#67) 2026-05-15 03:06:05 -07:00
sudacode 801cdcafca fix(docs): update Plausible proxy hostname to worker.sudacode.com
- Change `PLAUSIBLE_PROXY_HOSTNAME` from `worker.subminer.moe` to `worker.sudacode.com`
- Update test assertion to match new hostname
2026-05-15 02:13:20 -07:00
sudacode 094bcce0dc feat: add auto update support 2026-05-15 01:47:56 -07:00
sudacode d1ec678d7a fix(docs): proxy Plausible tracking script 2026-05-15 01:26:26 -07:00
sudacode f0324cd93a docs: simplify Windows setup instructions and remove experimental warnin
- Remove "Windows support is experimental" warning across README and docs
- Replace `bun run subminer` references with SubMiner mpv shortcut as recommended Windows launch method
- Remove `subminer doctor` step from README quickstart
- Clarify launcher script is primarily for Linux/macOS
2026-05-13 00:30:55 -07:00
sudacode 1b2ee03678 docs: add v0.14.0 bundled plugin migration note to changelog 2026-05-13 00:11:18 -07:00
216 changed files with 11687 additions and 1042 deletions
+24 -4
View File
@@ -47,6 +47,13 @@ jobs:
- name: Build (TypeScript check)
run: bun run typecheck
- name: Install Lua
run: |
sudo apt-get update
sudo apt-get install -y lua5.4
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
lua -v
- name: Test suite (source)
run: bun run test:fast
@@ -139,7 +146,10 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: appimage
path: release/*.AppImage
path: |
release/*.AppImage
release/*.yml
release/*.blockmap
if-no-files-found: error
build-macos:
@@ -216,6 +226,8 @@ jobs:
path: |
release/*.dmg
release/*.zip
release/*.yml
release/*.blockmap
if-no-files-found: error
build-windows:
@@ -267,6 +279,8 @@ jobs:
path: |
release/*.exe
release/*.zip
release/*.yml
release/*.blockmap
if-no-files-found: error
release:
@@ -339,7 +353,7 @@ jobs:
- name: Generate checksums
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz dist/launcher/subminer)
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/*.yml release/*.blockmap dist/launcher/subminer)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
@@ -355,8 +369,12 @@ jobs:
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: Verify committed prerelease notes
run: |
if [ ! -s release/prerelease-notes.md ]; then
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
exit 1
fi
- name: Publish Prerelease
env:
@@ -371,6 +389,8 @@ jobs:
release/*.exe
release/*.zip
release/*.tar.gz
release/*.yml
release/*.blockmap
release/SHA256SUMS.txt
dist/launcher/subminer
)
+11 -2
View File
@@ -137,7 +137,10 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: appimage
path: release/*.AppImage
path: |
release/*.AppImage
release/*.yml
release/*.blockmap
build-macos:
needs: [quality-gate]
@@ -213,6 +216,8 @@ jobs:
path: |
release/*.dmg
release/*.zip
release/*.yml
release/*.blockmap
build-windows:
needs: [quality-gate]
@@ -263,6 +268,8 @@ jobs:
path: |
release/*.exe
release/*.zip
release/*.yml
release/*.blockmap
if-no-files-found: error
release:
@@ -335,7 +342,7 @@ jobs:
- name: Generate checksums
run: |
shopt -s nullglob
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz dist/launcher/subminer)
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/*.yml release/*.blockmap dist/launcher/subminer)
if [ "${#files[@]}" -eq 0 ]; then
echo "No release artifacts found for checksum generation."
exit 1
@@ -389,6 +396,8 @@ jobs:
release/*.exe
release/*.zip
release/*.tar.gz
release/*.yml
release/*.blockmap
release/SHA256SUMS.txt
dist/launcher/subminer
)
+1
View File
@@ -10,6 +10,7 @@ dist/
release/*
!release/
!release/release-notes.md
!release/prerelease-notes.md
build/yomitan/
coverage/
+13 -4
View File
@@ -20,9 +20,10 @@ MACOS_APP_DIR ?= $(HOME)/Applications
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
# If building from source, the AppImage will typically land in release/.
APPIMAGE_SRC := $(firstword $(wildcard release/SubMiner-*.AppImage))
MACOS_APP_SRC := $(firstword $(wildcard release/*.app release/*/*.app))
MACOS_ZIP_SRC := $(firstword $(wildcard release/SubMiner-*.zip))
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
PRERELEASE_NOTES := release/prerelease-notes.md
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
ifeq ($(OS),Windows_NT)
@@ -161,7 +162,15 @@ build-launcher:
clean:
@printf '%s\n' "[INFO] Removing build artifacts"
@rm -rf dist release
@if [ -f "$(PRERELEASE_NOTES)" ]; then \
PRERELEASE_NOTES_BACKUP="$$(mktemp -t subminer-prerelease-notes.XXXXXX)" && \
cp "$(PRERELEASE_NOTES)" "$$PRERELEASE_NOTES_BACKUP" && \
rm -rf dist release && \
install -d release && \
mv "$$PRERELEASE_NOTES_BACKUP" "$(PRERELEASE_NOTES)"; \
else \
rm -rf dist release; \
fi
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
generate-config: ensure-bun
+10 -22
View File
@@ -205,7 +205,7 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~
```
> [!NOTE]
> The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. Make sure `bun` is on your `PATH`.
> The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory.
</details>
@@ -217,23 +217,22 @@ Download the latest DMG or ZIP from [GitHub Releases](https://github.com/ksyasud
Also download the `subminer` launcher (recommended):
```bash
sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer \
&& sudo chmod +x /usr/local/bin/subminer
mkdir -p ~/.local/bin
curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer
```
> [!NOTE]
> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. Make sure `bun` is on your `PATH`.
> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory. Make sure `~/.local/bin` is on your PATH before installing there.
</details>
<details>
<summary><b>Windows</b></summary>
Download the latest installer or portable `.zip` from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Make sure `mpv` is on your `PATH`.
Download the latest installer (`.exe`) [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Make sure `mpv` is on your `PATH`.
**Windows support is experimental.** Core features such as mining, annotations, and dictionary lookups work, but some functionality may be missing or unstable. Bug reports welcome.
**Note:** On Windows the `subminer` launcher requires [`bun`](https://bun.sh) and must be invoked with `bun run subminer` instead of running the script directly. The recommended alternative is the **SubMiner mpv** shortcut created during first-run setup — double-click it, drag files onto it, or run `SubMiner.exe --launch-mpv` from a terminal. See the [Windows mpv Shortcut](https://docs.subminer.moe/usage#windows-mpv-shortcut) section for details.
**Note:** On Windows the recommended way to run playback is with the **SubMiner mpv** shortcut created during first-run setup. First-run setup can also optionally install Bun and a `subminer.cmd` command shim to your user PATH, so new terminals can run `subminer` without adding `SubMiner.exe` to PATH.
</details>
@@ -250,23 +249,12 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
subminer app --setup # launch the first-run setup wizard
```
SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing the mpv plugin and configuring Yomitan dictionaries. Follow the on-screen steps to complete setup.
Jellyfin setup is available from the tray or `subminer jellyfin`; once Jellyfin is enabled with a server URL, the tray can toggle Jellyfin Discovery for the current app session.
SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing Yomitan dictionaries. The setup popup can also optionally install Bun and the `subminer` command-line launcher; those choices do not block setup completion.
> [!NOTE]
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
### 3. Verify Setup
```bash
subminer doctor # verify mpv, ffmpeg, config, and socket
```
> [!NOTE]
> On Windows, use `bun run subminer doctor` or run `SubMiner.exe` directly — first-run setup will guide you through dependency checks.
### 4. Mine
### 3. Mine
```bash
subminer video.mkv # play video with overlay
@@ -276,7 +264,7 @@ subminer stats -b # stats daemon in background
subminer stats -s # stop background stats daemon
```
On **Windows**, the `subminer` script must be run with `bun run subminer` (e.g. `bun run subminer video.mkv`). The recommended alternative is the **SubMiner mpv** shortcut (created during setup) or `SubMiner.exe --launch-mpv`. Drag a video file onto the shortcut to play it, or double-click it to open mpv with SubMiner's defaults.
On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup — double-click it to open mpv, or drag a video file onto it. You can also run `SubMiner.exe --launch-mpv` from a terminal.
## Documentation
+12 -17
View File
@@ -10,6 +10,7 @@
"@xhayper/discord-rpc": "^1.3.3",
"axios": "^1.13.5",
"commander": "^14.0.3",
"electron-updater": "^6.8.3",
"hono": "^4.12.7",
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
@@ -333,6 +334,8 @@
"electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
"electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="],
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -487,6 +490,10 @@
"lodash": ["lodash@4.18.0", "", {}, "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
"lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
@@ -627,7 +634,7 @@
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
@@ -681,6 +688,8 @@
"tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
"tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
@@ -745,12 +754,12 @@
"@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
"@electron/rebuild/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@electron/universal/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
"@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
@@ -765,14 +774,10 @@
"@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@npmcli/fs/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -787,8 +792,6 @@
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"global-agent/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -797,18 +800,10 @@
"minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"node-api-version/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"node-gyp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
"simple-update-notifier/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
"@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
+4
View File
@@ -0,0 +1,4 @@
type: added
area: updater
- Added tray and `subminer -u` update checks for SubMiner releases, including app update prompts, launcher updates, Linux rofi theme updates, checksum verification, configurable update notifications, and an opt-in prerelease update channel for beta/RC testing.
@@ -0,0 +1,4 @@
type: fixed
area: character-dictionary
- Reused cached character-dictionary media matches so loading a title with an existing snapshot no longer sends another AniList search request.
@@ -0,0 +1,6 @@
type: fixed
area: overlay
- Controller config and debug shortcuts now stay closed while controller support is disabled and show a notice to enable `controller.enabled` manually.
- Controller binding rows now start learn mode from the edit pencil, so clicking edit and pressing a controller button saves the remap.
- Controller remaps are now saved per controller profile, binding badges also start learn mode, and row reset buttons restore individual bindings to their defaults.
@@ -0,0 +1,5 @@
type: added
area: setup
- Added optional first-run setup controls to install Bun and the `subminer` command-line launcher on Linux, macOS, and Windows.
- Added a Windows `subminer.cmd` user PATH shim so users can type `subminer` without adding `SubMiner.exe` to PATH.
+6
View File
@@ -0,0 +1,6 @@
type: fixed
area: anilist
- Used fresh mpv time-position, duration, and subtitle timing events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold.
- Prefer season-specific AniList search results for multi-season files before falling back to the base title.
- Show a clear AniList message when the matched season is not in Planning or Watching instead of silently queueing an impossible progress update.
+10
View File
@@ -0,0 +1,10 @@
type: fixed
area: overlay
- Hid the macOS visible overlay when mpv is no longer the foreground target so other apps and Spaces are not covered by SubMiner subtitles.
- Kept the macOS overlay layered above active mpv while stats mouse passthrough is enabled, and treated the frontmost mpv app as the focus signal.
- Opened the stats overlay inactive on macOS so it appears over fullscreen mpv instead of switching back to SubMiner's original desktop.
- Preserved the active mpv focus state through transient macOS helper misses so subtitles do not flicker while mpv remains foreground.
- Kept fullscreen macOS overlays stable when mpv remains frontmost but window geometry temporarily disappears from the macOS window APIs.
- Released the macOS overlay when the helper reports mpv is no longer foreground so other apps are no longer covered.
- Reduced macOS window-tracker background work by preferring the compiled helper and slowing polls while mpv is stably focused.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed macOS overlay tracking so transient mpv window misses no longer hide the overlay; minimizing mpv still hides it.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed macOS overlay passthrough so mpv controls remain clickable before hovering subtitle bars.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed subtitle sync modal opens so macOS no longer flashes and hides the first modal attempt or leaves stale modal state after syncing.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: updater
- Made Linux `subminer -u` perform release updates from the launcher, independent of any running tray app instance, while reporting `up to date` without downloading assets when the latest release is not newer.
- Limited support asset updates to the Linux rofi theme.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Fixed Linux first-run launcher installs by building the packaged launcher with a valid Bun shebang.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Stopped Linux tray update checks from invoking the native Electron updater, using GitHub release metadata/assets instead so checks do not crash the tray app.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: setup
- First-run setup now recognizes installed macOS launchers in Homebrew or user PATH dirs, while manual setup installs avoid Homebrew-owned directories.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Fixed tray update checks for builds that cannot install native app updates, showing a manual install message instead of a restart prompt that cannot apply the update.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Bring macOS update dialogs to the front when `subminer --update` is run from the launcher.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: build
- Fixed one-shot `make clean build install` flows so install picks up the AppImage built earlier in the same make invocation.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updates
- Kept signed macOS app updates on the native updater path while preventing eager Squirrel install checks before the user confirms restart.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: release
- Prerelease note generation now reuses existing reviewed prerelease notes and asks Claude to merge only new fragment material, while `make clean` preserves `release/prerelease-notes.md`.
@@ -0,0 +1,4 @@
type: internal
area: tests
- Removed stale Yomitan vendor source-inspection assertions for changes that were not shipped.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Fixed `subminer app --setup` so it opens the setup flow when SubMiner is already running in the background.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: setup
- Quit standalone setup app launches after first-run setup finishes, returning the terminal instead of leaving the app process open.
+10
View File
@@ -0,0 +1,10 @@
type: fixed
area: tray
- Kept the tray app running when closing tray-launched Yomitan settings.
- Kept tray-launched Yomitan settings loading from blocking other tray actions.
- Replaced the default native Yomitan settings menu with a close-only menu so closing settings does not quit the tray app.
- Added an in-page close button for Yomitan settings on Hyprland, where native window controls are not available.
- Disabled Yomitan's embedded popup preview in the tray-launched settings window to avoid renderer hangs during normal sidebar navigation.
- Serialized copied Yomitan extension refreshes so startup cannot race itself and leave extension loading in an error state.
- Fixed tray-launched session help focus handling so the modal can close without mpv running.
+14 -1
View File
@@ -138,7 +138,8 @@
"axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ==========================================
@@ -155,6 +156,18 @@
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ==========================================
// Updates
// Automatic update check behavior.
// Manual checks from the tray or launcher are always allowed.
// ==========================================
"updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
+16
View File
@@ -1,4 +1,11 @@
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com';
const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js';
const PLAUSIBLE_ENDPOINT = `${PLAUSIBLE_PROXY_HOSTNAME}/api/event`;
const PLAUSIBLE_INIT_SCRIPT = [
'window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};',
`plausible.init({ endpoint: '${PLAUSIBLE_ENDPOINT}' });`,
].join('\n');
function pageToCanonicalHref(page: string): string | null {
if (page === '404.md') return null;
@@ -15,6 +22,15 @@ export default {
description:
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
head: [
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
[
'script',
{
async: '',
src: `${PLAUSIBLE_PROXY_HOSTNAME}${PLAUSIBLE_SITE_SCRIPT_PATH}`,
},
],
['script', {}, PLAUSIBLE_INIT_SCRIPT],
['link', { rel: 'icon', href: '/favicon.ico', sizes: 'any' }],
[
'link',
-28
View File
@@ -7,32 +7,7 @@ import './mermaid-modal.css';
import TuiLayout from './TuiLayout.vue';
let mermaidLoader: Promise<any> | null = null;
let plausibleTrackerInitialized = false;
const MERMAID_MODAL_ID = 'mermaid-diagram-modal';
const PLAUSIBLE_DOMAIN = 'subminer.moe';
const PLAUSIBLE_ENABLED_HOSTNAMES = new Set(['docs.subminer.moe']);
const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/capture';
async function initPlausibleTracker() {
if (typeof window === 'undefined' || plausibleTrackerInitialized) {
return;
}
if (!PLAUSIBLE_ENABLED_HOSTNAMES.has(window.location.hostname)) {
return;
}
const { init } = await import('@plausible-analytics/tracker');
init({
domain: PLAUSIBLE_DOMAIN,
endpoint: PLAUSIBLE_ENDPOINT,
outboundLinks: true,
fileDownloads: true,
formSubmissions: true,
captureOnLocalhost: false,
});
plausibleTrackerInitialized = true;
}
function closeMermaidModal() {
if (typeof document === 'undefined') {
@@ -222,9 +197,6 @@ export default {
};
onMounted(() => {
initPlausibleTracker().catch((error) => {
console.error('Failed to initialize Plausible tracker:', error);
});
render();
});
watch(() => route.path, render);
+29 -28
View File
@@ -17,8 +17,8 @@ AniList integration is opt-in. To enable it:
{
"anilist": {
"enabled": true,
"accessToken": ""
}
"accessToken": "",
},
}
```
@@ -37,20 +37,20 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
The update flow:
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
## Update Queue and Retry
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
| Parameter | Value |
| --- | --- |
| Initial backoff | 30 seconds |
| Maximum backoff | 6 hours |
| Maximum attempts | 8 |
| Queue capacity | 500 items |
| Parameter | Value |
| ---------------- | ---------- |
| Initial backoff | 30 seconds |
| Maximum backoff | 6 hours |
| Maximum attempts | 8 |
| Queue capacity | 500 items |
After 8 failed attempts, the update is moved to a dead-letter queue and no longer retried automatically. The queue is persisted across restarts so no updates are lost if SubMiner exits before a retry succeeds.
@@ -85,36 +85,37 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
"collapsibleSections": {
"description": false,
"characterInformation": false,
"voicedBy": false
}
}
}
"voicedBy": false,
},
},
},
}
```
| Option | Values | Description |
| --- | --- | --- |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
| Option | Values | Description |
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
## CLI Commands
| Command | Description |
| --- | --- |
| `--anilist-setup` | Open AniList setup/auth flow helper window |
| `--anilist-status` | Print current token resolution state and retry queue counters |
| `--anilist-logout` | Clear stored AniList token from local persisted state |
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
| Command | Description |
| ----------------------- | ------------------------------------------------------------- |
| `--anilist-setup` | Open AniList setup/auth flow helper window |
| `--anilist-status` | Print current token resolution state and retry queue counters |
| `--anilist-logout` | Clear stored AniList token from local persisted state |
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
## Troubleshooting
- **Updates not triggering:** Confirm `anilist.enabled` is `true`. SubMiner requires at least 85% of the episode watched and a minimum of 10 minutes. Short episodes or partial watches will not trigger an update.
- **Update not possible:** Add the season to your AniList Planning or Watching list first. SubMiner will not create new AniList list entries automatically.
- **Wrong episode or title matched:** Detection quality is best when `guessit` is installed and on your `PATH`. Without it, SubMiner falls back to internal filename parsing which can be less accurate with unusual naming conventions.
- **Token issues:** Run `--anilist-status` to check token state. If the token is invalid or expired, run `--anilist-setup` or `--anilist-logout` and re-authenticate.
- **Updates failing repeatedly:** Run `--anilist-status` to see retry queue counters. Items that fail 8 times are moved to the dead-letter queue. Check network connectivity and AniList API status.
-3
View File
@@ -8,7 +8,6 @@
"@catppuccin/vitepress": "^0.1.2",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/manrope": "^5.2.8",
"@plausible-analytics/tracker": "^0.4.4",
"mermaid": "^11.12.3",
},
"devDependencies": {
@@ -143,8 +142,6 @@
"@mermaid-js/parser": ["@mermaid-js/parser@1.0.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw=="],
"@plausible-analytics/tracker": ["@plausible-analytics/tracker@0.4.4", "", {}, "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
+2
View File
@@ -2,6 +2,8 @@
## v0.14.0 (2026-05-12)
SubMiner no longer requires a globally-installed mpv plugin. The bundled plugin is injected at runtime only when SubMiner launches mpv — through the `subminer` launcher, the app's managed launch, or the packaged Windows SubMiner mpv shortcut. When you open mpv on its own, SubMiner is not involved and the plugin is never loaded. If you have a legacy global SubMiner plugin under mpv's `scripts` directory, first-run setup detects it and prompts you to remove it before playback starts.
**Added**
- **Character Dictionary:** Added AniList-based character dictionary selection for resolving title mismatches — open it in-app with the new `Ctrl+Alt+A` shortcut or from the CLI with `subminer dictionary --candidates` / `--select`. Series-scoped overrides replace stale entries in the merged dictionary.
+2 -2
View File
@@ -35,7 +35,7 @@ Character dictionary sync is disabled by default. To turn it on:
```
::: tip
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot.
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
:::
::: warning
@@ -139,7 +139,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine
5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
6. **ready** — Dictionary is live. Character names will match on the next subtitle line.
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`:
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search.
```jsonc
{
+99 -63
View File
@@ -129,6 +129,7 @@ The configuration file includes several main sections:
- [**Stats Dashboard**](#stats-dashboard) - Local dashboard and overlay for immersion progress
- [**MPV Launcher**](#mpv-launcher) - mpv executable path and window launch mode
- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
- [**Updates**](#updates) - Automatic update checks, notifications, and prerelease testing
## Core Settings
@@ -148,6 +149,28 @@ Control the minimum log level for runtime output:
| ------- | ---------------------------------------- | --------------------------------------------------------- |
| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) |
### Updates
Configure automatic update checks and update notifications:
```json
{
"updates": {
"enabled": true,
"checkIntervalHours": 24,
"notificationType": "system",
"channel": "stable"
}
}
```
| Option | Values | Description |
| -------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
| `notificationType` | `"system"` \| `"osd"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"system"`. |
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
### Auto-Start Overlay
Control whether the overlay automatically becomes visible when it connects to mpv:
@@ -218,10 +241,10 @@ See `config.example.jsonc` for detailed configuration options.
}
```
| Option | Values | Description |
| --------- | ------------------------- | --------------------------------------------- |
| Option | Values | Description |
| --------- | ------------------------- | --------------------------------------------------- |
| `enabled` | `true`, `false`, `"auto"` | Built-in subtitle websocket mode (default: `false`) |
| `port` | number | WebSocket server port (default: 6677) |
| `port` | number | WebSocket server port (default: 6677) |
### Annotation WebSocket
@@ -258,10 +281,10 @@ See `config.example.jsonc` for detailed configuration options.
}
```
| Option | Values | Description |
| ----------------- | --------------- | ---------------------------------------------------------------------- |
| Option | Values | Description |
| ----------------- | --------------- | ----------------------------------------------------------------------- |
| `launchAtStartup` | `true`, `false` | Start texthooker automatically with SubMiner startup (default: `false`) |
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
## Subtitle Display
@@ -585,8 +608,12 @@ Important behavior:
- Controller input is only active while keyboard-only mode is enabled.
- Keyboard-only mode continues to work normally without a controller.
- By default SubMiner uses the first connected controller.
- Fresh installs keep controller support disabled until you set `controller.enabled` to `true`.
- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
- The `Alt+C` config modal and `Alt+Shift+C` debug modal stay closed while controller support is disabled.
- Click the binding badge, edit pencil, or `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
- Click the reset button beside the edit pencil to restore one binding to the built-in default.
- Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile.
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
@@ -635,6 +662,15 @@ Important behavior:
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
},
"profiles": {
"Xbox Wireless Controller": {
"label": "Xbox Wireless Controller",
"bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
"mineCard": { "kind": "button", "buttonIndex": 2 },
},
},
},
},
}
```
@@ -655,7 +691,7 @@ Default logical mapping:
- `L3`: toggle mpv pause
- `L2` / `R2`: unbound by default
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors for you, so manual edits are only needed when you want to script or copy exact mappings.
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors under `controller.profiles["<controller id>"]` for the selected controller. Manual edits are only needed when you want to script or copy exact mappings.
If you bind a discrete action to an axis manually, include `direction`:
@@ -669,15 +705,15 @@ If you bind a discrete action to an axis manually, include `direction`:
}
```
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings` or `controller.profiles.*.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
If one controller reports non-standard raw button numbers, override `controller.profiles["<controller id>"].buttonIndices` using values from the `Alt+Shift+C` debug modal. Use global `controller.buttonIndices` only when the mapping should apply to every controller without a profile.
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and profile `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
### Manual Card Update Shortcuts
@@ -859,59 +895,59 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
| `ankiConnect.deck` | string | Legacy Anki polling/compatibility scope. Newer known-word cache scoping should use `ankiConnect.knownWords.decks`. |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping for known-word cache queries (for example `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
| `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated sentence audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
+38 -11
View File
@@ -155,15 +155,29 @@ chmod +x ~/.local/bin/SubMiner.AppImage
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
chmod +x ~/.local/bin/subminer
# Download launcher support assets used for bundled runtime plugin injection
# Download the optional Linux rofi theme
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.local/share/SubMiner/plugin/subminer
cp -R /tmp/plugin/subminer/. ~/.local/share/SubMiner/plugin/subminer/
mkdir -p ~/.local/share/SubMiner/themes
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
```
The `subminer` launcher is the recommended way to use SubMiner on Linux. It ensures mpv is launched with the correct IPC socket, SubMiner defaults, and the bundled runtime plugin so you don't need to configure `mpv.conf` or install a global mpv plugin.
The first-run setup window can also install Bun and the packaged `subminer` launcher into an existing writable PATH directory. Both steps are optional.
To check for updates later:
```bash
subminer -u
# or
subminer --update
```
SubMiner verifies launcher and Linux rofi theme downloads against `SHA256SUMS.txt`. If the launcher is installed in a protected path such as `/usr/local/bin/subminer`, SubMiner does not elevate itself; it shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead.
On Linux, `subminer -u` performs this update from the launcher process, so it does not need to start or IPC into the tray app.
### From Source
```bash
@@ -204,6 +218,8 @@ brew install mecab mecab-ipadic
The `subminer` launcher is the recommended way to use SubMiner on macOS. It launches mpv with the correct IPC socket and SubMiner defaults so you don't need to set up an `mpv.conf` profile manually.
First-run setup can install Bun and the packaged launcher into a writable directory that is already on PATH. It does not edit shell profiles.
Download it from the same [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) page:
```bash
@@ -218,6 +234,16 @@ sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/sub
sudo chmod +x /usr/local/bin/subminer
```
To check for updates later:
```bash
subminer -u
# or
subminer --update
```
SubMiner verifies launcher downloads against `SHA256SUMS.txt`. If `/usr/local/bin/subminer` is protected, SubMiner shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead of elevating itself.
::: warning Bun required for the launcher
The `subminer` launcher uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`. Install Bun if you haven't already: `curl -fsSL https://bun.sh/install | bash`.
:::
@@ -245,7 +271,7 @@ Build and install the launcher alongside the app:
make install-macos
```
This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle and rofi theme. To install to `/usr/local/bin` instead (already on the default macOS `PATH`):
This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle. To install to `/usr/local/bin` instead (already on the default macOS `PATH`):
```bash
sudo make install-macos PREFIX=/usr/local
@@ -301,9 +327,6 @@ binary_path=/Applications/SubMiner.app/Contents/MacOS/subminer
## Windows
> [!WARNING]
> **Windows support is experimental.** Core features — mining, annotations, and dictionary lookups — work, but some functionality may be missing or unstable. Bug reports welcome.
### Prerequisites
1. Install [`mpv`](https://mpv.io/installation/) and ensure `mpv.exe` is on `PATH`. If mpv is installed elsewhere, you can set `mpv.executablePath` in `config.jsonc` or use the first-run setup field to point at the executable.
@@ -323,7 +346,8 @@ Download the latest Windows installer from [GitHub Releases](https://github.com/
1. **Run `SubMiner.exe` once** — first-run setup creates `%APPDATA%\SubMiner\config.jsonc` and opens Yomitan settings for dictionary import. The global mpv plugin install is optional for compatibility; the SubMiner mpv shortcut injects the bundled runtime plugin.
2. **Create the SubMiner mpv shortcut** _(recommended)_ — the setup popup offers to create a `SubMiner mpv` Start Menu and/or Desktop shortcut. This is the recommended way to launch playback on Windows.
3. **Play a video** — double-click the shortcut, drag a video file onto it, or run from a terminal:
3. **Optional: install the command-line launcher** — first-run setup can install Bun with winget/Scoop/the official installer and add `%LOCALAPPDATA%\SubMiner\bin\subminer.cmd` to your user PATH. Open a new terminal and type `subminer`.
4. **Play a video** — double-click the shortcut, drag a video file onto it, or run from a terminal:
```powershell
& "C:\Program Files\SubMiner\SubMiner.exe" --launch-mpv "C:\Videos\episode 01.mkv"
@@ -333,7 +357,9 @@ The shortcut and `--launch-mpv` pass SubMiner's default IPC socket, subtitle arg
### Windows-Specific Notes
- The `subminer` launcher script requires [Bun](https://bun.sh) and must be invoked with `bun run subminer` on Windows since the shebang is not supported. The **SubMiner mpv** shortcut or `SubMiner.exe --launch-mpv` is the simpler alternative.
- The **SubMiner mpv** shortcut created during first-run setup is the recommended way to launch playback on Windows.
- The optional command-line launcher installs a `subminer.cmd` shim, but users type `subminer`; Windows resolves `.cmd` through `PATHEXT`.
- First-run setup adds only `%LOCALAPPDATA%\SubMiner\bin` to the HKCU user PATH. It does not add `SubMiner.exe` or the app install directory to PATH.
- First-run plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is in a non-standard location.
- Plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket` — do not keep `/tmp/subminer-socket` on Windows.
- Config is stored at `%APPDATA%\SubMiner\config.jsonc`.
@@ -397,8 +423,9 @@ The setup popup walks you through:
- **mpv plugin**: install the bundled Lua plugin for in-player keybindings
- **Yomitan dictionaries**: import at least one dictionary so lookups work
- **Windows shortcut** _(Windows only)_: optionally create a `SubMiner mpv` Start Menu/Desktop shortcut
- **Command line launcher**: optionally install Bun and the `subminer` launcher to your command-line PATH
The `Finish setup` button stays disabled until the plugin is installed and at least one dictionary is imported. Once you finish, SubMiner will not show the popup again.
The `Finish setup` button follows the normal config/Yomitan readiness checks. Bun and the command-line launcher are optional and never block setup completion.
> [!TIP]
> You can re-open the setup popup at any time with `subminer app --setup` or `SubMiner.AppImage --setup`.
@@ -442,7 +469,7 @@ subminer doctor
This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix any failures before continuing.
> [!NOTE]
> On Windows, use `bun run subminer doctor` or run `SubMiner.exe` directly. Replace `SubMiner.AppImage` with `SubMiner.exe` in the direct app commands below.
> On Windows, run `SubMiner.exe` directly. Replace `SubMiner.AppImage` with `SubMiner.exe` in the direct app commands below.
## Optional Extras
+5 -1
View File
@@ -3,7 +3,7 @@
The `subminer` launcher is an all-in-one script that handles video selection, mpv startup, and overlay management. It is the recommended way to use SubMiner on Linux and macOS because it guarantees mpv is launched with the correct IPC socket and SubMiner defaults. It's a Bun script distributed as a release asset alongside the AppImage and DMG.
::: tip Windows users
On Windows the `subminer` script cannot run directly via shebang — use `bun run subminer` instead (e.g. `bun run subminer video.mkv`). The recommended alternative is the **SubMiner mpv** shortcut created during first-run setup, or `SubMiner.exe --launch-mpv`. See [Windows mpv Shortcut](/usage#windows-mpv-shortcut) for details.
On Windows, the recommended way to launch playback is the **SubMiner mpv** shortcut created during first-run setup — double-click it, drag a file onto it, or run `SubMiner.exe --launch-mpv` from a terminal. See [Windows mpv Shortcut](/usage#windows-mpv-shortcut) for details.
:::
## Video Picker
@@ -63,6 +63,7 @@ SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
subminer video.mkv # play a specific file (default plugin config auto-starts visible overlay)
subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
subminer --backend x11 video.mkv # Force x11 backend for a specific file
subminer -u # check for SubMiner updates
subminer stats # open immersion dashboard
subminer stats -b # start background stats daemon
```
@@ -98,6 +99,7 @@ Use `subminer <subcommand> -h` for command-specific help.
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `--setup` | Open first-run setup popup manually |
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
| `--start` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
| `-T, --no-texthooker` | Disable texthooker server |
@@ -107,6 +109,8 @@ Use `subminer <subcommand> -h` for command-specific help.
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, and rofi theme even when SubMiner is already running in the tray.
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
## Logging
+1 -1
View File
@@ -65,7 +65,7 @@ With a gamepad connected and keyboard-only mode enabled, the full mining loop wo
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
After controller support is enabled, the controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
-1
View File
@@ -14,7 +14,6 @@
"@catppuccin/vitepress": "^0.1.2",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/manrope": "^5.2.8",
"@plausible-analytics/tracker": "^0.4.4",
"mermaid": "^11.12.3"
},
"devDependencies": {
+25 -16
View File
@@ -3,25 +3,34 @@ import { readFileSync } from 'node:fs';
const docsConfigPath = new URL('./.vitepress/config.ts', import.meta.url);
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
const docsPackagePath = new URL('./package.json', import.meta.url);
const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
const docsPackageContents = readFileSync(docsPackagePath, 'utf8');
test('docs site keeps docs hostname while sending plausible events to subminer.moe via worker.subminer.moe capture endpoint', () => {
test('docs site loads the docs.subminer.moe Plausible script through the analytics proxy', () => {
expect(docsConfigContents).toContain("const DOCS_HOSTNAME = 'https://docs.subminer.moe'");
expect(docsConfigContents).toContain('hostname: DOCS_HOSTNAME');
expect(docsThemeContents).toContain("const PLAUSIBLE_DOMAIN = 'subminer.moe'");
expect(docsThemeContents).toContain('const PLAUSIBLE_ENABLED_HOSTNAMES = new Set([');
expect(docsThemeContents).toContain("'docs.subminer.moe'");
expect(docsThemeContents).toContain(
"const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/capture'",
expect(docsConfigContents).toContain(
"const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com'",
);
expect(docsThemeContents).toContain('@plausible-analytics/tracker');
expect(docsThemeContents).toContain('const { init } = await import');
expect(docsThemeContents).toContain('!PLAUSIBLE_ENABLED_HOSTNAMES.has(window.location.hostname)');
expect(docsThemeContents).toContain('domain: PLAUSIBLE_DOMAIN');
expect(docsThemeContents).toContain('endpoint: PLAUSIBLE_ENDPOINT');
expect(docsThemeContents).toContain('outboundLinks: true');
expect(docsThemeContents).toContain('fileDownloads: true');
expect(docsThemeContents).toContain('formSubmissions: true');
expect(docsThemeContents).toContain('captureOnLocalhost: false');
expect(docsConfigContents).toContain(
"const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js'",
);
expect(docsConfigContents).toContain(
'const PLAUSIBLE_ENDPOINT = `${PLAUSIBLE_PROXY_HOSTNAME}/api/event`',
);
expect(docsConfigContents).toContain('hostname: DOCS_HOSTNAME');
expect(docsConfigContents).toContain("rel: 'preconnect'");
expect(docsConfigContents).toContain('href: PLAUSIBLE_PROXY_HOSTNAME');
expect(docsConfigContents).toContain("async: ''");
expect(docsConfigContents).toContain(
'src: `${PLAUSIBLE_PROXY_HOSTNAME}${PLAUSIBLE_SITE_SCRIPT_PATH}`',
);
expect(docsConfigContents).toContain('plausible.init({ endpoint:');
expect(docsConfigContents).toContain('PLAUSIBLE_ENDPOINT');
expect(docsConfigContents).not.toContain("'data-domain'");
expect(docsConfigContents).not.toContain("'data-api'");
expect(docsThemeContents).not.toContain('@plausible-analytics/tracker');
expect(docsThemeContents).not.toContain('initPlausibleTracker');
expect(docsPackageContents).not.toContain('@plausible-analytics/tracker');
});
+14 -1
View File
@@ -138,7 +138,8 @@
"axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ==========================================
@@ -155,6 +156,18 @@
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ==========================================
// Updates
// Automatic update check behavior.
// Manual checks from the tray or launcher are always allowed.
// ==========================================
"updates": {
"enabled": true, // Run automatic update checks in the background. Values: true | false
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
"notificationType": "system", // How SubMiner announces available updates. Values: system | osd | both | none
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
}, // Automatic update check behavior.
// ==========================================
// Keyboard Shortcuts
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
+24
View File
@@ -100,6 +100,30 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless — SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
## Updates
**"Update check failed"**
Manual update checks show this when GitHub Releases or updater metadata cannot be reached. Check your network connection, then try again from the tray menu or:
```bash
subminer -u
```
Automatic checks log failures quietly so playback is not interrupted.
**"SubMiner is up to date" but a prerelease exists**
SubMiner defaults to stable GitHub releases. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases.
**Launcher update shows a sudo command**
The detected launcher is installed in a protected path such as `/usr/local/bin/subminer` or `/usr/bin/subminer`. SubMiner does not elevate itself. Run the command shown in the popup to replace the launcher after checksum verification.
**OSD update notification did not appear**
`updates.notificationType: "osd"` uses the existing mpv/overlay notification path. If mpv is disconnected, SubMiner logs the update and does not force-start the overlay. Use `"system"` or `"both"` if you want OS notifications outside playback.
## AnkiConnect
**"AnkiConnect: unable to connect"**
+9 -8
View File
@@ -37,7 +37,7 @@ Field names must match your Anki note type exactly (case-sensitive). See [Anki I
There are several ways to use SubMiner:
> [!TIP]
> **New users: start with the `subminer` wrapper script** (or the **SubMiner mpv** shortcut on Windows). It handles mpv launch, IPC socket setup, and overlay lifecycle automatically so you don't need to configure anything in `mpv.conf`.
> **New users on Linux/macOS: start with the `subminer` wrapper script.** On Windows, use the **SubMiner mpv** shortcut created during first-run setup. Both handle mpv launch, IPC socket setup, and overlay lifecycle automatically so you don't need to configure anything in `mpv.conf`.
| Approach | Use when | How |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
@@ -45,7 +45,7 @@ There are several ways to use SubMiner:
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults directly. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
| **MPV plugin** (all platforms) | You launch mpv yourself or from another tool (file manager, Jellyfin, etc.). Requires `--input-ipc-server=/tmp/subminer-socket` in your mpv config. | Use `y` chord keybindings inside mpv |
You can use both — the plugin provides in-player controls, while the `subminer` script (or the Windows shortcut) is convenient for direct playback. The `subminer` script runs directly via shebang on Linux and macOS (no `bun run` needed); on Windows it must be invoked with `bun run subminer` since the shebang is not supported.
You can use both — the plugin provides in-player controls, while the `subminer` script (Linux/macOS) or the SubMiner mpv shortcut (Windows) is convenient for direct playback.
## Live Config Reload
@@ -283,13 +283,14 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
### Getting Started
1. Connect a controller before or after launching SubMiner.
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
2. Set `controller.enabled` to `true` in your config.
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
4. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
5. Click the binding badge, edit pencil, or `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
6. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
7. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
By default SubMiner uses the first connected controller after controller support is enabled. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline per controller. The reset button beside each edit pencil restores that binding to its built-in default for the selected controller. `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both modals stay closed while `controller.enabled` is false, and both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
### Default Button Mapping
@@ -316,7 +317,7 @@ By default SubMiner uses the first connected controller. `Alt+C` opens the contr
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
All button and axis mappings are configurable under the `controller` config block. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
## Keybindings
+17 -3
View File
@@ -31,6 +31,9 @@
`bun run test:fast`
`bun run test:env`
`bun run build`
When validating auto-update metadata, also run the relevant platform package
build and confirm `release/` contains the generated updater metadata
(`*.yml`) and blockmaps (`*.blockmap`).
8. If `docs-site/` changed, also run:
`bun run docs:test`
`bun run docs:build`
@@ -50,7 +53,15 @@
`bun run test:fast`
`bun run test:env`
`bun run build`
5. Commit the prerelease prep. Do not run `bun run changelog:build`.
When validating packaged updater output, confirm the platform build writes
`*.yml` and `*.blockmap` files under `release/`.
5. Commit the prerelease prep (package.json version bump + the generated
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
committed file — so review it before committing. If you add more
`changes/*.md` fragments for a later beta/RC, rerun
`bun run changelog:prerelease-notes --version <version>`; the generator uses
the existing prerelease notes as the baseline and asks Claude to merge only
the new fragment material. Do not run `bun run changelog:build`.
6. Tag the commit: `git tag v<version>`.
7. Push commit + tag.
@@ -62,13 +73,16 @@ Notes:
- 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:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- 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.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
- If you need to repair a published release body (for example, a prior versions 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.
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
+33
View File
@@ -8,6 +8,7 @@ import { runDictionaryCommand } from './dictionary-command.js';
import { runDoctorCommand } from './doctor-command.js';
import { runMpvPreAppCommand } from './mpv-command.js';
import { runStatsCommand } from './stats-command.js';
import { runUpdateCommand } from './update-command.js';
class ExitSignal extends Error {
code: number;
@@ -240,6 +241,38 @@ test('dictionary command returns after app handoff starts', () => {
assert.equal(handled, true);
});
test('update command runs direct Linux release update without launching Electron', async () => {
const context = createContext();
context.args.update = true;
const calls: string[] = [];
const handled = await runUpdateCommand(context, {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
runDirectReleaseUpdate: async (request) => {
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
return {
appImage: { status: 'not-found' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
};
},
readMainConfig: () => null,
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'direct:/tmp/subminer.app:/tmp/subminer:stable',
'info:AppImage update: not-found',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
]);
});
test('stats command launches attached app command with response path', async () => {
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
const handled = await runStatsCommand(harness.context, harness.commandDeps);
+140
View File
@@ -0,0 +1,140 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runUpdateCommand } from './update-command';
import type { LauncherCommandContext } from './context';
function makeContext(overrides: Partial<LauncherCommandContext> = {}): LauncherCommandContext {
return {
args: {
update: true,
logLevel: 'warn',
} as LauncherCommandContext['args'],
scriptPath: '/home/kyle/.local/bin/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {} as LauncherCommandContext['pluginRuntimeConfig'],
appPath: '/home/kyle/.local/bin/SubMiner.AppImage',
launcherJellyfinConfig: {} as LauncherCommandContext['launcherJellyfinConfig'],
processAdapter: {
platform: () => 'linux',
} as LauncherCommandContext['processAdapter'],
...overrides,
};
}
test('runUpdateCommand updates directly on Linux without launching Electron', async () => {
const calls: string[] = [];
const handled = await runUpdateCommand(makeContext(), {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
runDirectReleaseUpdate: async (request) => {
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
return {
appImage: { status: 'updated' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
};
},
readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
'info:AppImage update: updated',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
]);
});
test('runUpdateCommand skips Linux asset replacement when release is not newer', async () => {
const calls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string) => {
calls.push(`fetch:${url}`);
if (!url.endsWith('/releases')) {
throw new Error(`unexpected asset fetch: ${url}`);
}
return {
ok: true,
status: 200,
json: async () => [
{
tag_name: 'v0.14.0',
prerelease: false,
draft: false,
assets: [
{
name: 'SHA256SUMS.txt',
browser_download_url: 'https://example.test/SHA256SUMS.txt',
},
{
name: 'SubMiner.AppImage',
browser_download_url: 'https://example.test/SubMiner.AppImage',
},
],
},
],
text: async () => '',
arrayBuffer: async () => new ArrayBuffer(0),
};
}) as typeof fetch;
try {
const handled = await runUpdateCommand(makeContext(), {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
readMainConfig: () => null,
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
'info:AppImage update: up to date',
'info:Launcher update: up to date',
'info:Rofi theme update: up to date',
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('runUpdateCommand keeps app-mediated update path on non-Linux', async () => {
const calls: string[] = [];
const handled = await runUpdateCommand(
makeContext({
processAdapter: {
platform: () => 'darwin',
} as LauncherCommandContext['processAdapter'],
appPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
}),
{
createTempDir: () => '/tmp/subminer-update-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandCaptureOutput: (appPath, appArgs) => {
calls.push(`app:${appPath}:${appArgs.join(' ')}`);
return { status: 0, stdout: '', stderr: '' };
},
waitForUpdateResponse: async () => ({ ok: true, status: 'up-to-date' }),
removeDir: (targetPath) => {
calls.push(`remove:${targetPath}`);
},
},
);
assert.equal(handled, true);
assert.deepEqual(calls, [
'app:/Applications/SubMiner.app/Contents/MacOS/SubMiner:--update --update-launcher-path /home/kyle/.local/bin/subminer --update-response-path /tmp/subminer-update-test/response.json',
'remove:/tmp/subminer-update-test',
]);
});
+220
View File
@@ -0,0 +1,220 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import packageJson from '../../package.json';
import { runAppCommandCaptureOutput } from '../mpv.js';
import { log as launcherLog } from '../log.js';
import { nowMs } from '../time.js';
import { sleep } from '../util.js';
import type { LauncherCommandContext } from './context.js';
import { readLauncherMainConfigObject } from '../config/shared-config-reader.js';
import type { UpdateChannel } from '../../src/types/config.js';
import { updateAppImageFromRelease } from '../../src/main/runtime/update/appimage-updater.js';
import { updateLauncherFromRelease } from '../../src/main/runtime/update/launcher-updater.js';
import {
compareSemverLike,
fetchLatestStableRelease,
fetchReleaseAssetBuffer,
fetchReleaseAssetText,
findReleaseAsset,
parseReleaseVersion,
parseSha256Sums,
type FetchLike,
} from '../../src/main/runtime/update/release-assets.js';
import { updateSupportAssetsFromRelease } from '../../src/main/runtime/update/support-assets.js';
type UpdateCommandResponse = {
ok: boolean;
status?: string;
version?: string;
error?: string;
};
type DirectReleaseUpdateRequest = {
appPath: string;
launcherPath: string;
channel: UpdateChannel;
};
type DirectReleaseUpdateResult = {
appImage: { status: string; command?: string; message?: string };
launcher: { status: string; command?: string; message?: string };
supportAssets: Array<{ status: string; command?: string; message?: string }>;
};
type UpdateCommandDeps = {
createTempDir: (prefix: string) => string;
joinPath: (...parts: string[]) => string;
runAppCommandCaptureOutput: (
appPath: string,
appArgs: string[],
) => { status: number; stdout: string; stderr: string; error?: Error };
waitForUpdateResponse: (responsePath: string) => Promise<UpdateCommandResponse>;
removeDir: (targetPath: string) => void;
runDirectReleaseUpdate: (
request: DirectReleaseUpdateRequest,
) => Promise<DirectReleaseUpdateResult>;
readMainConfig: () => Record<string, unknown> | null;
log: typeof launcherLog;
};
const UPDATE_RESPONSE_TIMEOUT_MS = 10 * 60 * 1000;
const CURRENT_VERSION = packageJson.version;
function getFetchForLauncherUpdater(): FetchLike {
return globalThis.fetch.bind(globalThis) as FetchLike;
}
async function runDirectReleaseUpdate(
request: DirectReleaseUpdateRequest,
): Promise<DirectReleaseUpdateResult> {
const fetchForUpdater = getFetchForLauncherUpdater();
const release = await fetchLatestStableRelease({
fetch: fetchForUpdater,
channel: request.channel,
});
const releaseVersion = parseReleaseVersion(release);
if (releaseVersion && compareSemverLike(releaseVersion, CURRENT_VERSION) <= 0) {
return {
appImage: { status: 'up-to-date' },
launcher: { status: 'up-to-date' },
supportAssets: [{ status: 'up-to-date' }],
};
}
const sumsAsset = release ? findReleaseAsset(release, 'SHA256SUMS.txt') : null;
const sha256Sums =
sumsAsset && release
? parseSha256Sums(
await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url),
)
: new Map<string, string>();
const downloadAsset = (url: string) => fetchReleaseAssetBuffer(fetchForUpdater, url);
const [appImage, launcher, supportAssets] = await Promise.all([
updateAppImageFromRelease({
release,
sha256Sums,
appImagePath: request.appPath,
downloadAsset,
}),
updateLauncherFromRelease({
release,
sha256Sums,
launcherPath: request.launcherPath,
downloadAsset,
}),
updateSupportAssetsFromRelease({
release,
sha256Sums,
downloadAsset,
}),
]);
return { appImage, launcher, supportAssets };
}
function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel {
const updates =
root?.updates && typeof root.updates === 'object' && !Array.isArray(root.updates)
? (root.updates as Record<string, unknown>)
: null;
return updates?.channel === 'prerelease' ? 'prerelease' : 'stable';
}
function logUpdateResult(
label: string,
result: { status: string; command?: string; message?: string },
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
deps: Pick<UpdateCommandDeps, 'log'>,
): void {
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
deps.log('info', configuredLogLevel, `${label} update: ${displayStatus}`);
if (result.command) {
deps.log(
'warn',
configuredLogLevel,
`${label} update requires manual command: ${result.command}`,
);
} else if (result.message) {
deps.log('warn', configuredLogLevel, `${label} update note: ${result.message}`);
}
}
const defaultDeps: UpdateCommandDeps = {
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
joinPath: (...parts) => path.join(...parts),
runAppCommandCaptureOutput: (appPath, appArgs) => runAppCommandCaptureOutput(appPath, appArgs),
waitForUpdateResponse: async (responsePath) => {
const deadline = nowMs() + UPDATE_RESPONSE_TIMEOUT_MS;
while (nowMs() < deadline) {
try {
if (fs.existsSync(responsePath)) {
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as UpdateCommandResponse;
}
} catch {
// retry until timeout
}
await sleep(100);
}
return { ok: false, error: 'Timed out waiting for SubMiner update response.' };
},
removeDir: (targetPath) => {
fs.rmSync(targetPath, { recursive: true, force: true });
},
runDirectReleaseUpdate,
readMainConfig: readLauncherMainConfigObject,
log: launcherLog,
};
export async function runUpdateCommand(
context: LauncherCommandContext,
deps: Partial<UpdateCommandDeps> = {},
): Promise<boolean> {
const resolvedDeps: UpdateCommandDeps = { ...defaultDeps, ...deps };
const { args, appPath, scriptPath } = context;
if (!args.update || !appPath) {
return false;
}
if (context.processAdapter.platform() === 'linux') {
const result = await resolvedDeps.runDirectReleaseUpdate({
appPath,
launcherPath: scriptPath,
channel: readUpdateChannel(resolvedDeps.readMainConfig()),
});
const logLevel = args.logLevel ?? 'warn';
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
for (const supportResult of result.supportAssets) {
logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps);
}
return true;
}
const tempDir = resolvedDeps.createTempDir('subminer-update-');
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
try {
const result = resolvedDeps.runAppCommandCaptureOutput(appPath, [
'--update',
'--update-launcher-path',
scriptPath,
'--update-response-path',
responsePath,
]);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`SubMiner update command exited with status ${result.status}.`);
}
const response = await resolvedDeps.waitForUpdateResponse(responsePath);
if (!response.ok) {
throw new Error(response.error || 'SubMiner update check failed.');
}
return true;
} finally {
resolvedDeps.removeDir(tempDir);
}
}
+2
View File
@@ -156,6 +156,7 @@ export function createDefaultArgs(
statsCleanupLifetime: false,
doctor: false,
doctorRefreshKnownWords: false,
update: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -217,6 +218,7 @@ export function applyRootOptionsToArgs(
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
if (options.rofi === true) parsed.useRofi = true;
if (options.update === true) parsed.update = true;
if (options.startOverlay === true) parsed.autoStartOverlay = true;
if (options.texthooker === false) parsed.useTexthooker = false;
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
+1
View File
@@ -57,6 +57,7 @@ function applyRootOptions(program: Command): void {
.option('-p, --profile <profile>', 'MPV profile')
.option('--start', 'Explicitly start overlay')
.option('--log-level <level>', 'Log level')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-S, --start-overlay', 'Auto-start overlay')
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
+5
View File
@@ -18,6 +18,7 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
import { runStatsCommand } from './commands/stats-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runPlaybackCommand } from './commands/playback-command.js';
import { runUpdateCommand } from './commands/update-command.js';
function createCommandContext(
args: ReturnType<typeof parseArgs>,
@@ -86,6 +87,10 @@ async function main(): Promise<void> {
return;
}
if (await runUpdateCommand(appContext)) {
return;
}
if (await runMpvPostAppCommand(appContext)) {
return;
}
+19
View File
@@ -1315,6 +1315,25 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo
});
}
export function runAppCommandSilently(appPath: string, appArgs: string[]): void {
if (maybeCaptureAppArgs(appArgs)) {
process.exit(0);
}
const target = resolveAppSpawnTarget(appPath, appArgs);
const proc = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(),
});
attachAppProcessLogging(proc);
proc.once('error', (error) => {
fail(`Failed to run app command: ${error.message}`);
});
proc.once('close', (code) => {
process.exit(code ?? 0);
});
}
export function runAppCommandCaptureOutput(
appPath: string,
appArgs: string[],
+12
View File
@@ -57,6 +57,18 @@ test('parseArgs captures mpv args string', () => {
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
});
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
const shortParsed = parseArgs(['-u'], 'subminer', {});
const longParsed = parseArgs(['--update'], 'subminer', {});
const jellyfinParsed = parseArgs(['jellyfin', 'setup', '-u', 'kyle'], 'subminer', {});
assert.equal(shortParsed.update, true);
assert.equal(longParsed.update, true);
assert.equal(jellyfinParsed.update, false);
assert.equal(jellyfinParsed.jellyfin, true);
assert.equal(jellyfinParsed.jellyfinUsername, 'kyle');
});
test('parseArgs maps jellyfin play action and log-level override', () => {
const parsed = parseArgs(['jellyfin', 'play', '--log-level', 'debug'], 'subminer', {});
+26 -10
View File
@@ -3,11 +3,17 @@ import assert from 'node:assert/strict';
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
import type { SetupState } from '../src/shared/setup-state';
const commandLineSetupDefaults = {
bunInstallStatus: 'unknown',
launcherInstallStatus: 'unknown',
launcherInstallPath: null,
} satisfies Pick<SetupState, 'bunInstallStatus' | 'launcherInstallStatus' | 'launcherInstallPath'>;
test('waitForSetupCompletion resolves completed and cancelled states', async () => {
const sequence: Array<SetupState | null> = [
null,
{
version: 3,
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
@@ -17,9 +23,10 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
},
{
version: 3,
version: 4,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
@@ -29,6 +36,7 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'skipped',
...commandLineSetupDefaults,
},
];
@@ -56,7 +64,7 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
if (reads === 1) return null;
if (reads === 2) {
return {
version: 3,
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
@@ -66,10 +74,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
}
return {
version: 3,
version: 4,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
@@ -79,6 +88,7 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'installed',
...commandLineSetupDefaults,
};
},
launchSetupApp: () => {
@@ -125,7 +135,7 @@ test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal'
readSetupState: () => {
reads += 1;
return {
version: 3,
version: 4,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
completionSource: 'user',
@@ -135,6 +145,7 @@ test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal'
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
},
hasLegacyMpvPlugin: () => legacyPluginInstalled,
@@ -164,7 +175,7 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
readSetupState: () => {
reads += 1;
return {
version: 3,
version: 4,
status: 'completed',
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z',
completionSource: 'user',
@@ -174,6 +185,7 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
},
hasLegacyMpvPlugin: () => true,
@@ -196,7 +208,7 @@ test('ensureLauncherSetupReady lets users continue without removing a legacy mpv
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
const result = await ensureLauncherSetupReady({
readSetupState: () => ({
version: 3,
version: 4,
status: 'cancelled',
completedAt: null,
completionSource: null,
@@ -206,6 +218,7 @@ test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
}),
launchSetupApp: () => undefined,
sleep: async () => undefined,
@@ -228,7 +241,7 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
reads += 1;
if (reads <= 2) {
return {
version: 3,
version: 4,
status: 'cancelled',
completedAt: null,
completionSource: null,
@@ -238,11 +251,12 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
}
if (reads === 3) {
return {
version: 3,
version: 4,
status: 'in_progress',
completedAt: null,
completionSource: null,
@@ -252,10 +266,11 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
}
return {
version: 3,
version: 4,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'legacy_auto_detected',
@@ -265,6 +280,7 @@ test('ensureLauncherSetupReady ignores stale cancelled state after launching set
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
...commandLineSetupDefaults,
};
},
launchSetupApp: () => undefined,
+1
View File
@@ -134,6 +134,7 @@ export interface Args {
dictionaryTarget?: string;
doctor: boolean;
doctorRefreshKnownWords: boolean;
update?: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
+18 -5
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.14.0",
"version": "0.15.0-beta.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -15,7 +15,7 @@
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
"build:yomitan": "bun scripts/build-yomitan.mjs",
"build:assets": "bun scripts/prepare-build-assets.mjs",
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=dist/launcher/subminer",
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
"build:stats": "cd stats && bun run build",
"dev:stats": "cd stats && bun run dev",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
@@ -47,8 +47,8 @@
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts",
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
@@ -70,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/card-creation-manual-update.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",
"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/card-creation-manual-update.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/get-mpv-window-macos.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",
@@ -111,6 +111,7 @@
"@xhayper/discord-rpc": "^1.3.3",
"axios": "^1.13.5",
"commander": "^14.0.3",
"electron-updater": "^6.8.3",
"hono": "^4.12.7",
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
@@ -136,6 +137,14 @@
"directories": {
"output": "release"
},
"publish": [
{
"provider": "github",
"owner": "ksyasuda",
"repo": "SubMiner"
}
],
"electronUpdaterCompatibility": ">=2.16",
"linux": {
"target": [
"AppImage"
@@ -236,6 +245,10 @@
"from": "plugin/subminer.conf",
"to": "plugin/subminer.conf"
},
{
"from": "dist/launcher/subminer",
"to": "launcher/subminer"
},
{
"from": "dist/scripts/get-mpv-window-windows.ps1",
"to": "scripts/get-mpv-window-windows.ps1"
+44
View File
@@ -0,0 +1,44 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
## Highlights
### Added
**Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`. Checks include checksum verification, configurable notifications, and an opt-in channel for prerelease builds. The `subminer` launcher and Linux rofi theme are also updated automatically.
**First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH.
### Fixed
**macOS Overlay:** Significantly improved overlay focus and stability — the overlay now hides when mpv loses focus or is minimized, stays stable through transient window-tracking misses, remains correctly layered during stats mouse passthrough, and opens over fullscreen mpv without switching Spaces. Passthrough is also fixed so mpv controls stay clickable before hovering a subtitle bar. Background tracking overhead is reduced while mpv is stably focused.
**Subtitle Sync Modal:** Fixed a macOS issue where opening the subtitle sync modal would flash and disappear on the first attempt, or leave stale state after syncing.
**Controller:** Controller config and debug shortcuts now stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge, remaps are saved per controller profile, and individual bindings can be reset to their defaults.
**AniList Progress:** Progress threshold checks now use fresh playback position data, so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status.
**Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
**Updater — Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists.
**Updater — macOS:** Update dialogs now come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native updater path without triggering premature Squirrel install checks.
**Setup — macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, returning control to the terminal.
**Launcher — Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
**Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running.
**Build — Linux Install:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
-50
View File
@@ -1,50 +0,0 @@
## Highlights
### Added
- **Character Dictionary:** Added AniList-based selection to resolve character dictionary mismatches, with series-scoped overrides that replace stale entries. Available via `subminer dictionary --candidates` / `--select` and a default `Ctrl+Alt+A` in-app shortcut.
- **Subtitle Bar Toggle:** Added a `V` shortcut and mpv binding to toggle the primary subtitle bar independently of mpv's native subtitle display.
- **Texthooker:** Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser.
### Changed
- **mpv Plugin Setup:** Managed launches now inject the bundled plugin automatically. The setup flow can trash detected legacy global plugin files before launch, and legacy global install entrypoints have been removed so regular mpv playback is unaffected.
- **Tray Menu:** Replaced "Open Overlay" with "Open Help," which opens the session help modal.
- **Stats Exclusions:** Vocabulary exclusions now persist in the immersion database and migrate existing browser-local exclusions on first load.
- **Config Defaults:** Disabled texthooker startup, subtitle, and annotation websocket servers by default. Fresh installs now use a Japanese font stack, transparent subtitle backgrounds, stronger text shadows, and teal N4/fourth-band coloring for primary subtitles. Yomitan popup auto-pause remains enabled.
- **Config Example:** The generated example config now lists every built-in keybinding default.
### Fixed
- **Subtitle Annotations — Grammar Filtering:** Suppressed N+1, JLPT, frequency, and name styling on grammar-only tokens: standalone interjections (`あ`, katakana variants), kana grammar helpers (`ことに`), auxiliary inflection fragments (`れる`, `れた`), polite copula tails (`です`, `じゃないですか`), standalone particles matched by known-word decks, and existence verbs (`ある`/`有る`). Known-word highlighting is preserved where applicable.
- **Subtitle Annotations — Color Priority:** Fixed token color priority so typography settings are preserved, JLPT colors no longer override higher-priority known-word or frequency colors, JLPT underlines persist at their correct color after dictionary lookups and when a token also carries known-word or frequency annotations, and frequency highlighting works correctly for ordinal prefix-noun tokens like `第二`.
- **Subtitle Annotations — Other:** Stopped kana-only tokens from being selected as N+1 targets; preserved Yomitan compound tokens so known component words no longer color a larger unknown word green; kept annotation prefetch running after immediate cache-hit renders; added a brightness lift for annotated token hover states when hover backgrounds are transparent; accepted `subtitleStyle.hoverBackground` as an alias for `subtitleStyle.hoverTokenBackgroundColor`; refreshed the current subtitle after successful card mining so newly known words recolor immediately.
- **Subtitle Bar:** Changed `v` to cycle the primary subtitle bar through visible, hover, and hidden modes with OSD feedback. Added `subtitleStyle.primaryDefaultMode` to set the startup visibility default independently from secondary subtitles.
- **Tokenizer:** Now uses Yomitan `wordClasses` metadata for part-of-speech filtering, and backfills blank MeCab POS fields during parser enrichment.
- **Overlay (Linux):** Fixed multi-line subtitle copy timing out after the prompt; follow-up number-row digits are now accepted for multi-line mining even when the original shortcut modifiers are still held.
- **Overlay (Hyprland):** Fixed fullscreen transitions so overlay geometry refreshes on mpv fullscreen changes, topmost stacking is reasserted, and hover pause works correctly after resize/toggle cycles. Overlay windows now align precisely to mpv bounds with floating decoration disabled; the stats overlay is opaque to prevent mpv bleed-through at the top edge; overlay windows no longer pin across workspaces.
- **Overlay (macOS):** Kept the overlay visible and interactive during transient tracker refreshes while mpv is the active tracked window, and kept it behind unrelated foreground windows while remaining above mpv.
- **Overlay:** Keyboard-only Yomitan popup shortcuts now take precedence over overlay keybindings like `j`; the browser focus outline is hidden so focused overlays no longer show a yellow/orange viewport border.
- **Default Keybindings:** Fixed replay/next subtitle keybindings — session help moved to `Ctrl/Cmd+/`, freeing `Ctrl+Shift+H` and `Ctrl+Shift+L` for subtitle playback controls. `Ctrl+Shift+L` now correctly reaches play-next-subtitle, and play-next resumes from a paused state before pausing again at the subtitle end.
- **Anki:** Manual clipboard subtitle updates preserve existing word audio while replacing sentence audio, animated-image media, and expression fields — even when audio overwrite is configured off.
- **AniList:** Post-watch progress checks now run on time-position updates using the fresh mpv position; manual mark-watched forces a progress sync; missing episode metadata is filled from the filename parser. Duplicate writes during concurrent checks are prevented, and manual watched marks are preserved when sync fails.
- **AniList (Linux):** Retried safeStorage availability after transient keyring failures so tokens can load and save once the keyring becomes available. Prevented config reload from opening the setup window during playback when token storage cannot be resolved, and stopped the setup flow from reporting success when token persistence fails.
- **mpv:** Stopped mpv from holding SubMiner subprocesses during shutdown, preventing desktop crash notifications on video close. Kept the overlay alive across same-media buffering reloads to avoid duplicate startup gates and AniSkip lookups; playlist navigation now reuses the running overlay without repeating the pause-until-ready warmup gate.
- **Launcher:** Managed playback now exits the background SubMiner app when the video closes; explicit background launches remain persistent.
- **Stats:** Background mode routes through the isolated stats daemon; app startup defers to an already-running daemon instead of failing when the port is already in use. Fixed recent session detail pages showing "Media not found" before lifetime media summaries are available.
- **Jellyfin:** Improved setup with recent server selection and inline authentication feedback. Added a tray toggle for runtime-only cast discovery.
### Docs
- Improved the docs homepage with canonical URLs and a cleaner sitemap.
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+66
View File
@@ -509,6 +509,72 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
}
});
test('writePrereleaseNotesForVersion reuses existing prerelease notes when adding new fragments', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-reuse-existing-notes');
const projectRoot = path.join(workspace, 'SubMiner');
const existingNotes = [
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
'',
'## Highlights',
'### Added',
'- Overlay: Previous beta entry.',
'',
'## Installation',
'',
'See the README and docs/installation guide for full setup steps.',
'',
'## Assets',
'',
'- Linux: `SubMiner.AppImage`',
'',
].join('\n');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.2' }, null, 2),
'utf8',
);
fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: fixed', 'area: launcher', '', '- Fixed launcher prerelease packaging.'].join('\n'),
'utf8',
);
try {
const stub = recordingRunClaude((input) => {
if (!input.includes('Overlay: Previous beta entry.')) {
return '### Fixed\n- Launcher: Added only the latest fix.';
}
return [
'### Added',
'- Overlay: Previous beta entry.',
'',
'### Fixed',
'- Launcher: Added only the latest fix.',
].join('\n');
});
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.2',
deps: { runClaude: stub.runClaude },
});
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
assert.match(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/);
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /- Overlay: Previous beta entry\./);
assert.match(prereleaseNotes, /- Launcher: Added only the latest fix\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-rc-notes');
+26 -3
View File
@@ -290,6 +290,7 @@ function serializeFragmentsForPrompt(
mode: PolishMode,
version: string,
date?: string,
existingReleaseNotes?: string,
): string {
const header: string[] = [`MODE: ${mode}`, `VERSION: ${version}`];
if (date) {
@@ -307,7 +308,11 @@ function serializeFragmentsForPrompt(
].join('\n');
});
return [...header, '', ...fragmentBlocks].join('\n\n');
const existingNotesBlock = existingReleaseNotes?.trim()
? ['EXISTING PRERELEASE NOTES', existingReleaseNotes.trim()]
: [];
return [...header, '', ...existingNotesBlock, '', ...fragmentBlocks].join('\n\n');
}
function validatePolishedOutput(
@@ -340,10 +345,11 @@ function polishFragmentsWithClaude(
mode: PolishMode;
version: string;
date?: string;
existingReleaseNotes?: string;
deps?: ChangelogFsDeps;
},
): string {
const { mode, version, date } = options;
const { mode, version, date, existingReleaseNotes } = options;
const runClaude = options.deps?.runClaude ?? defaultRunClaude;
const filtered =
@@ -361,8 +367,18 @@ function polishFragmentsWithClaude(
);
}
const reuseInstructions = existingReleaseNotes?.trim()
? [
'## Existing Prerelease Notes',
'',
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Reuse those highlight bullets as the baseline, preserve their meaning and wording where possible, then merge in only new or changed fragment material. Deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, Installation, or Assets sections.',
'',
].join('\n')
: '';
const prompt =
POLISH_PROMPT_INSTRUCTIONS + serializeFragmentsForPrompt(filtered, mode, version, date);
POLISH_PROMPT_INSTRUCTIONS +
reuseInstructions +
serializeFragmentsForPrompt(filtered, mode, version, date, existingReleaseNotes);
const output = runClaude(prompt, CLAUDE_CLI_ARGS);
return validatePolishedOutput(output, mode, hasInternalFragments);
}
@@ -780,6 +796,8 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
verifyRequestedVersionMatchesPackageVersion(options ?? {});
const cwd = options?.cwd ?? process.cwd();
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const version = resolveVersion(options ?? {});
if (!isSupportedPrereleaseVersion(version)) {
throw new Error(
@@ -792,9 +810,14 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
throw new Error('No changelog fragments found in changes/.');
}
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
const existingReleaseNotes = existsSync(prereleaseNotesPath)
? readFileSync(prereleaseNotesPath, 'utf8')
: undefined;
const changes = polishFragmentsWithClaude(fragments, {
mode: 'release-notes',
version,
existingReleaseNotes,
deps: options?.deps,
});
return writeReleaseNotesFile(cwd, changes, options?.deps, {
+102 -19
View File
@@ -7,7 +7,7 @@
// It works with both bundled and unbundled mpv installations.
//
// Usage: swift get-mpv-window-macos.swift
// Output: "x,y,width,height" or "not-found"
// Output: "x,y,width,height,focused", "minimized", "active", "inactive", or "not-found"
//
import Cocoa
@@ -25,6 +25,18 @@ private struct WindowState {
let focused: Bool
}
private struct FrontmostApplicationState {
let pid: pid_t
let isMpv: Bool
}
private enum WindowLookupResult {
case visible(WindowState)
case minimized
case active
case inactive
}
private let targetMpvSocketPath: String? = {
guard CommandLine.arguments.count > 1 else {
return nil
@@ -141,11 +153,44 @@ private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? {
return geometry
}
private func frontmostApplicationPid() -> pid_t? {
NSWorkspace.shared.frontmostApplication?.processIdentifier
private func frontmostApplicationState() -> FrontmostApplicationState? {
guard let app = NSWorkspace.shared.frontmostApplication else {
return nil
}
return FrontmostApplicationState(
pid: app.processIdentifier,
isMpv: app.localizedName.map(normalizedMpvName) ?? false
)
}
private func windowStateFromAccessibilityAPI() -> WindowState? {
private func isFocusedMpvWindow(ownerPid: pid_t, frontmost: FrontmostApplicationState?) -> Bool {
guard let frontmost = frontmost else {
return false
}
if frontmost.pid == ownerPid {
return true
}
return frontmost.isMpv && windowHasTargetSocket(ownerPid)
}
private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool {
guard let frontmost = frontmost, frontmost.isMpv else {
return false
}
if windowHasTargetSocket(frontmost.pid) {
return true
}
// When macOS says mpv is frontmost but geometry APIs miss, keep the
// overlay stable even if ps cannot expose the socket argument.
return targetMpvSocketPath != nil
}
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
let runningApps = NSWorkspace.shared.runningApplications.filter { app in
guard let name = app.localizedName else {
return false
@@ -153,7 +198,8 @@ private func windowStateFromAccessibilityAPI() -> WindowState? {
return normalizedMpvName(name)
}
let frontmostPid = frontmostApplicationPid()
let frontmost = frontmostApplicationState()
var foundMinimizedTargetWindow = false
for app in runningApps {
let appElement = AXUIElementCreateApplication(app.processIdentifier)
@@ -168,14 +214,12 @@ private func windowStateFromAccessibilityAPI() -> WindowState? {
}
for window in windows {
var minimizedRef: CFTypeRef?
let minimizedStatus = AXUIElementCopyAttributeValue(window, kAXMinimizedAttribute as CFString, &minimizedRef)
if minimizedStatus == .success, let minimized = minimizedRef as? Bool, minimized {
var windowPid: pid_t = 0
if AXUIElementGetPid(window, &windowPid) != .success {
continue
}
var windowPid: pid_t = 0
if AXUIElementGetPid(window, &windowPid) != .success {
if windowPid != app.processIdentifier {
continue
}
@@ -183,15 +227,28 @@ private func windowStateFromAccessibilityAPI() -> WindowState? {
continue
}
var minimizedRef: CFTypeRef?
let minimizedStatus = AXUIElementCopyAttributeValue(window, kAXMinimizedAttribute as CFString, &minimizedRef)
if minimizedStatus == .success, let minimized = minimizedRef as? Bool, minimized {
foundMinimizedTargetWindow = true
continue
}
if let geometry = geometryFromAXWindow(window) {
return WindowState(
geometry: geometry,
focused: frontmostPid == windowPid
return .visible(
WindowState(
geometry: geometry,
focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)
)
)
}
}
}
if foundMinimizedTargetWindow {
return .minimized
}
return nil
}
@@ -200,7 +257,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
// Use on-screen layer-0 windows to avoid off-screen helpers/shadows.
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
let frontmostPid = frontmostApplicationPid()
let frontmost = frontmostApplicationState()
for window in windowList {
guard let ownerName = window[kCGWindowOwnerName as String] as? String,
@@ -243,17 +300,43 @@ private func windowStateFromCoreGraphics() -> WindowState? {
return WindowState(
geometry: geometry,
focused: frontmostPid == ownerPid
focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)
)
}
return nil
}
if let window = windowStateFromAccessibilityAPI() ?? windowStateFromCoreGraphics() {
print(
"\(window.geometry.x),\(window.geometry.y),\(window.geometry.width),\(window.geometry.height),\(window.focused ? 1 : 0)"
)
private let lookupResult: WindowLookupResult? = {
if let axResult = windowStateFromAccessibilityAPI() {
return axResult
}
if let cgWindow = windowStateFromCoreGraphics() {
return .visible(cgWindow)
}
let frontmost = frontmostApplicationState()
if isFrontmostTargetMpv(frontmost) {
return .active
}
if frontmost != nil {
return .inactive
}
return nil
}()
if let result = lookupResult {
switch result {
case .visible(let window):
print(
"\(window.geometry.x),\(window.geometry.y),\(window.geometry.width),\(window.geometry.height),\(window.focused ? 1 : 0)"
)
case .minimized:
print("minimized")
case .active:
print("active")
case .inactive:
print("inactive")
}
} else {
print("not-found")
}
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync('scripts/get-mpv-window-macos.swift', 'utf8');
test('minimized Accessibility windows are validated by PID and socket before reporting minimized', () => {
const minimizedAssignmentIndex = source.indexOf('foundMinimizedTargetWindow = true');
assert.notEqual(minimizedAssignmentIndex, -1);
const loopStartIndex = source.lastIndexOf('for window in windows', minimizedAssignmentIndex);
assert.notEqual(loopStartIndex, -1);
const pidExtractionIndex = source.indexOf(
'AXUIElementGetPid(window, &windowPid)',
loopStartIndex,
);
const appPidMatchIndex = source.indexOf('windowPid != app.processIdentifier', loopStartIndex);
const socketCheckIndex = source.indexOf('if !windowHasTargetSocket(windowPid)', loopStartIndex);
assert.ok(
pidExtractionIndex > loopStartIndex && pidExtractionIndex < minimizedAssignmentIndex,
'window PID must be extracted before accepting a minimized window',
);
assert.ok(
appPidMatchIndex > pidExtractionIndex && appPidMatchIndex < minimizedAssignmentIndex,
'window PID must match the owning app before accepting a minimized window',
);
assert.ok(
socketCheckIndex > appPidMatchIndex && socketCheckIndex < minimizedAssignmentIndex,
'target socket must be validated before accepting a minimized window',
);
});
test('focused mpv window follows the frontmost mpv app signal', () => {
const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow');
assert.notEqual(focusHelperIndex, -1);
const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1);
const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex);
assert.ok(
focusHelperBody.includes('frontmost.pid == ownerPid'),
'matching frontmost PID should mark the mpv window focused',
);
assert.ok(
focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'),
'frontmost mpv app should mark the target mpv window focused even when PIDs differ',
);
assert.ok(
source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'),
'Accessibility path should use the shared focused mpv helper',
);
assert.ok(
source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'),
'CoreGraphics path should use the shared focused mpv helper',
);
});
test('frontmost mpv app emits active state when geometry lookup misses', () => {
assert.ok(
/case\s+\.active:/.test(source),
'helper should expose an active state without window geometry',
);
assert.ok(
source.includes('if windowHasTargetSocket(frontmost.pid)'),
'active state should still accept a matching target socket when available',
);
assert.ok(
source.includes('return targetMpvSocketPath != nil'),
'active state should preserve frontmost mpv even if command-line socket detection fails',
);
assert.ok(
source.includes('return .active'),
'lookup should preserve active mpv state after geometry lookup misses',
);
assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker');
});
test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => {
assert.ok(
/case\s+\.inactive:/.test(source),
'helper should expose an inactive state without window geometry',
);
assert.ok(
source.includes('if frontmost != nil'),
'helper should distinguish a known non-mpv frontmost app from an unknown miss',
);
assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive');
assert.ok(
source.includes('print("inactive")'),
'inactive state should be printed for the tracker',
);
});
+24
View File
@@ -51,6 +51,24 @@ test('parseArgs ignores missing value after --log-level', () => {
assert.equal(args.start, true);
});
test('parseArgs captures update command and internal launcher paths', () => {
const args = parseArgs([
'--update',
'--update-launcher-path',
'/home/kyle/.local/bin/subminer',
'--update-response-path',
'/tmp/subminer-update-response.json',
]);
assert.equal(args.update, true);
assert.equal(args.updateLauncherPath, '/home/kyle/.local/bin/subminer');
assert.equal(args.updateResponsePath, '/tmp/subminer-update-response.json');
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
assert.equal(commandNeedsOverlayRuntime(args), false);
assert.equal(shouldRunSettingsOnlyStartup(args), false);
});
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']);
assert.equal(args.launchMpv, true);
@@ -182,6 +200,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(refreshKnownWords), true);
assert.equal(isHeadlessInitialCommand(refreshKnownWords), true);
const update = parseArgs(['--update']);
assert.equal(update.update, true);
assert.equal(hasExplicitCommand(update), true);
assert.equal(shouldStartApp(update), true);
assert.equal(isHeadlessInitialCommand(update), true);
const settings = parseArgs(['--settings']);
assert.equal(settings.settings, true);
assert.equal(hasExplicitCommand(settings), true);
+26 -3
View File
@@ -73,6 +73,9 @@ export interface CliArgs {
texthooker: boolean;
texthookerOpenBrowser: boolean;
help: boolean;
update?: boolean;
updateLauncherPath?: string;
updateResponsePath?: string;
autoStartOverlay: boolean;
generateConfig: boolean;
configPath?: string;
@@ -167,6 +170,9 @@ export function parseArgs(argv: string[]): CliArgs {
texthooker: false,
texthookerOpenBrowser: false,
help: false,
update: false,
updateLauncherPath: undefined,
updateResponsePath: undefined,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
@@ -330,7 +336,20 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
else if (arg === '--texthooker') args.texthooker = true;
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
else if (arg === '--update') args.update = true;
else if (arg.startsWith('--update-launcher-path=')) {
const value = arg.split('=', 2)[1];
if (value) args.updateLauncherPath = value;
} else if (arg === '--update-launcher-path') {
const value = readValue(argv[i + 1]);
if (value) args.updateLauncherPath = value;
} else if (arg.startsWith('--update-response-path=')) {
const value = arg.split('=', 2)[1];
if (value) args.updateResponsePath = value;
} else if (arg === '--update-response-path') {
const value = readValue(argv[i + 1]);
if (value) args.updateResponsePath = value;
} else if (arg === '--auto-start-overlay') args.autoStartOverlay = true;
else if (arg === '--generate-config') args.generateConfig = true;
else if (arg === '--backup-overwrite') args.backupOverwrite = true;
else if (arg === '--help') args.help = true;
@@ -517,13 +536,14 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.jellyfinRemoteAnnounce ||
args.jellyfinPreviewAuth ||
args.texthooker ||
args.update ||
args.generateConfig ||
args.help
);
}
export function isHeadlessInitialCommand(args: CliArgs): boolean {
return args.refreshKnownWords;
return args.refreshKnownWords || args.update === true;
}
export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
@@ -587,6 +607,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.jellyfinPlay &&
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.update &&
!args.help &&
!args.autoStartOverlay &&
!args.generateConfig
@@ -638,7 +659,8 @@ export function shouldStartApp(args: CliArgs): boolean {
args.stats ||
args.jellyfin ||
args.jellyfinPlay ||
args.texthooker
args.texthooker ||
args.update
) {
if (args.launchMpv) {
return false;
@@ -708,6 +730,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.texthooker &&
!args.update &&
!args.help &&
!args.autoStartOverlay &&
!args.generateConfig &&
+1
View File
@@ -17,6 +17,7 @@ ${B}Session${R}
--stats Open the stats dashboard in your browser
--texthooker Start texthooker server only ${D}(no overlay)${R}
--open-browser Open texthooker in your default browser
--update Check for updates
${B}Overlay${R}
--toggle-visible-overlay Toggle subtitle overlay
+159
View File
@@ -109,6 +109,58 @@ test('loads defaults when config is missing', () => {
assert.equal(config.immersionTracking.lifetimeSummaries?.anime, true);
assert.equal(config.immersionTracking.lifetimeSummaries?.media, true);
assert.equal(config.stats.autoOpenBrowser, false);
assert.equal(config.updates.enabled, true);
assert.equal(config.updates.checkIntervalHours, 24);
assert.equal(config.updates.notificationType, 'system');
assert.equal(config.updates.channel, 'stable');
});
test('parses updates config and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"updates": {
"enabled": false,
"checkIntervalHours": 6,
"notificationType": "both",
"channel": "prerelease"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().updates.enabled, false);
assert.equal(validService.getConfig().updates.checkIntervalHours, 6);
assert.equal(validService.getConfig().updates.notificationType, 'both');
assert.equal(validService.getConfig().updates.channel, 'prerelease');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"updates": {
"enabled": "yes",
"checkIntervalHours": 0,
"notificationType": "toast",
"channel": "nightly"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
const config = invalidService.getConfig();
const warnings = invalidService.getWarnings();
assert.equal(config.updates.enabled, DEFAULT_CONFIG.updates.enabled);
assert.equal(config.updates.checkIntervalHours, DEFAULT_CONFIG.updates.checkIntervalHours);
assert.equal(config.updates.notificationType, DEFAULT_CONFIG.updates.notificationType);
assert.equal(config.updates.channel, DEFAULT_CONFIG.updates.channel);
assert.ok(warnings.some((warning) => warning.path === 'updates.enabled'));
assert.ok(warnings.some((warning) => warning.path === 'updates.checkIntervalHours'));
assert.ok(warnings.some((warning) => warning.path === 'updates.notificationType'));
assert.ok(warnings.some((warning) => warning.path === 'updates.channel'));
});
test('throws actionable startup parse error for malformed config at construction time', () => {
@@ -1401,6 +1453,104 @@ test('parses descriptor-based controller bindings', () => {
});
});
test('parses controller profiles as per-gamepad binding overrides', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"buttonIndices": {
"buttonSouth": 0,
"leftTrigger": 6
},
"bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
"quitMpv": "leftTrigger"
},
"profiles": {
"8BitDo SN30": {
"label": "8BitDo SN30",
"bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": 11 },
"leftStickVertical": { "kind": "axis", "axisIndex": 7, "dpadFallback": "none" }
}
},
"Xbox Wireless Controller": {
"buttonIndices": {
"leftTrigger": 8
},
"bindings": {
"quitMpv": "leftTrigger"
}
}
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.toggleLookup, {
kind: 'button',
buttonIndex: 11,
});
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.closeLookup, {
kind: 'button',
buttonIndex: 1,
});
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.leftStickVertical, {
kind: 'axis',
axisIndex: 7,
dpadFallback: 'none',
});
assert.deepEqual(config.controller.profiles['Xbox Wireless Controller']?.bindings.quitMpv, {
kind: 'button',
buttonIndex: 8,
});
assert.equal(
config.controller.profiles['Xbox Wireless Controller']?.buttonIndices.leftTrigger,
8,
);
assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 });
});
test('rejects reserved controller profile ids from config', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"profiles": {
"__proto__": { "label": "polluted" },
"constructor": { "label": "ctor" },
"prototype": { "label": "proto" },
"pad-1": { "label": "Pad 1" }
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(Object.hasOwn(config.controller.profiles, '__proto__'), false);
assert.equal(Object.hasOwn(config.controller.profiles, 'constructor'), false);
assert.equal(Object.hasOwn(config.controller.profiles, 'prototype'), false);
assert.equal(config.controller.profiles['pad-1']?.label, 'Pad 1');
assert.equal(
warnings.some((warning) => warning.path === 'controller.profiles.constructor'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.profiles.prototype'),
true,
);
});
test('controller descriptor config rejects malformed binding objects', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -2124,6 +2274,7 @@ test('template generator includes known keys', () => {
assert.match(output, /"websocket":/);
assert.match(output, /"discordPresence":/);
assert.match(output, /"startupWarmups":/);
assert.match(output, /"updates":/);
assert.match(output, /"youtube":/);
assert.doesNotMatch(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/);
@@ -2210,6 +2361,14 @@ test('template generator includes known keys', () => {
output,
/"autoOpenBrowser": false,? \/\/ Automatically open the stats dashboard in a browser when the server starts\. Values: true \| false/,
);
assert.match(
output,
/"notificationType": "system",? \/\/ How SubMiner announces available updates\. Values: system \| osd \| both \| none/,
);
assert.match(
output,
/"channel": "stable",? \/\/ Release channel used for update checks\. Values: stable \| prerelease/,
);
assert.match(
output,
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./,
+2
View File
@@ -33,6 +33,7 @@ const {
youtube,
subsync,
startupWarmups,
updates,
auto_start_overlay,
} = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
@@ -55,6 +56,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
youtube,
subsync,
startupWarmups,
updates,
subtitleStyle,
subtitleSidebar,
auto_start_overlay,
+8
View File
@@ -14,6 +14,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'youtube'
| 'subsync'
| 'startupWarmups'
| 'updates'
| 'auto_start_overlay'
> = {
subtitlePosition: { yPercent: 10 },
@@ -73,6 +74,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
profiles: {},
},
shortcuts: {
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
@@ -116,5 +118,11 @@ export const CORE_DEFAULT_CONFIG: Pick<
subtitleDictionaries: true,
jellyfinRemoteSession: true,
},
updates: {
enabled: true,
checkIntervalHours: 24,
notificationType: 'system',
channel: 'stable',
},
auto_start_overlay: false,
};
@@ -22,6 +22,7 @@ test('config option registry includes critical paths and has unique entries', ()
'controller.enabled',
'controller.scrollPixelsPerSecond',
'startupWarmups.lowPowerMode',
'updates.channel',
'youtube.primarySubLanguages',
'subtitleStyle.enableJlpt',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
+33
View File
@@ -239,6 +239,13 @@ export function buildCoreConfigOptionRegistry(
description:
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
},
{
path: 'controller.profiles',
kind: 'object',
defaultValue: defaultConfig.controller.profiles,
description:
'Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.',
},
...discreteBindings.flatMap((binding) => [
{
path: `controller.bindings.${binding.id}`,
@@ -383,6 +390,32 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.startupWarmups.jellyfinRemoteSession,
description: 'Warm up Jellyfin remote session at startup.',
},
{
path: 'updates.enabled',
kind: 'boolean',
defaultValue: defaultConfig.updates.enabled,
description: 'Run automatic update checks in the background.',
},
{
path: 'updates.checkIntervalHours',
kind: 'number',
defaultValue: defaultConfig.updates.checkIntervalHours,
description: 'Minimum hours between automatic update checks.',
},
{
path: 'updates.notificationType',
kind: 'enum',
enumValues: ['system', 'osd', 'both', 'none'],
defaultValue: defaultConfig.updates.notificationType,
description: 'How SubMiner announces available updates.',
},
{
path: 'updates.channel',
kind: 'enum',
enumValues: ['stable', 'prerelease'],
defaultValue: defaultConfig.updates.channel,
description: 'Release channel used for update checks.',
},
{
path: 'shortcuts.multiCopyTimeoutMs',
kind: 'number',
@@ -53,6 +53,14 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
],
key: 'startupWarmups',
},
{
title: 'Updates',
description: [
'Automatic update check behavior.',
'Manual checks from the tray or launcher are always allowed.',
],
key: 'updates',
},
{
title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
+423
View File
@@ -0,0 +1,423 @@
import type {
ControllerAxisBinding,
ControllerAxisDirection,
ControllerButtonBinding,
ControllerButtonIndicesConfig,
ControllerDpadFallback,
ResolvedControllerAxisBinding,
ResolvedControllerBindingsConfig,
ResolvedControllerDiscreteBinding,
} from '../../types/runtime';
import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared';
const CONTROLLER_BUTTON_BINDINGS = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const CONTROLLER_AXIS_BINDINGS = [
'leftStickX',
'leftStickY',
'rightStickX',
'rightStickY',
] as const;
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
leftStickX: 0,
leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
};
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
Exclude<ControllerButtonBinding, 'none'>,
keyof Required<ControllerButtonIndicesConfig>
> = {
select: 'select',
buttonSouth: 'buttonSouth',
buttonEast: 'buttonEast',
buttonNorth: 'buttonNorth',
buttonWest: 'buttonWest',
leftShoulder: 'leftShoulder',
rightShoulder: 'rightShoulder',
leftStickPress: 'leftStickPress',
rightStickPress: 'rightStickPress',
leftTrigger: 'leftTrigger',
rightTrigger: 'rightTrigger',
};
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
leftStickHorizontal: 'horizontal',
leftStickVertical: 'vertical',
rightStickHorizontal: 'none',
rightStickVertical: 'none',
} as const satisfies Record<string, ControllerDpadFallback>;
const CONTROLLER_BUTTON_INDEX_KEYS = [
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const CONTROLLER_DISCRETE_BINDING_KEYS = [
'toggleLookup',
'closeLookup',
'toggleKeyboardOnlyMode',
'mineCard',
'quitMpv',
'previousAudio',
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
] as const;
const CONTROLLER_AXIS_BINDING_KEYS = [
'leftStickHorizontal',
'leftStickVertical',
'rightStickHorizontal',
'rightStickVertical',
] as const;
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
return value === 'negative' || value === 'positive';
}
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
return value === 'none' || value === 'horizontal' || value === 'vertical';
}
function resolveLegacyDiscreteBinding(
value: ControllerButtonBinding,
buttonIndices: Required<ControllerButtonIndicesConfig>,
): ResolvedControllerDiscreteBinding {
if (value === 'none') {
return { kind: 'none' };
}
return {
kind: 'button',
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
};
}
function resolveLegacyAxisBinding(
value: ControllerAxisBinding,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding {
return {
kind: 'axis',
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
if (!isObject(value) || typeof value.kind !== 'string') return null;
if (value.kind === 'none') {
return { kind: 'none' };
}
if (value.kind === 'button') {
return typeof value.buttonIndex === 'number' &&
Number.isInteger(value.buttonIndex) &&
value.buttonIndex >= 0
? { kind: 'button', buttonIndex: value.buttonIndex }
: null;
}
if (value.kind === 'axis') {
return typeof value.axisIndex === 'number' &&
Number.isInteger(value.axisIndex) &&
value.axisIndex >= 0 &&
isControllerAxisDirection(value.direction)
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
: null;
}
return null;
}
function parseAxisBindingObject(
value: unknown,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding | null {
if (isObject(value) && value.kind === 'none') {
return { kind: 'none' };
}
if (!isObject(value) || value.kind !== 'axis') return null;
if (
typeof value.axisIndex !== 'number' ||
!Number.isInteger(value.axisIndex) ||
value.axisIndex < 0
) {
return null;
}
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
return null;
}
return {
kind: 'axis',
axisIndex: value.axisIndex,
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
function applyControllerButtonIndices(
source: unknown,
target: ControllerButtonIndicesTarget,
pathPrefix: string,
warn: ResolveContext['warn'],
): void {
if (!isObject(source)) return;
for (const key of CONTROLLER_BUTTON_INDEX_KEYS) {
const value = asNumber(source[key]);
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
target[key] = value;
} else if (source[key] !== undefined) {
warn(`${pathPrefix}.${key}`, source[key], target[key], 'Expected non-negative integer.');
}
}
}
function applyControllerBindings(
source: unknown,
target: ControllerBindingsTarget,
buttonIndices: ControllerButtonIndicesTarget,
pathPrefix: string,
warn: ResolveContext['warn'],
): void {
if (!isObject(source)) return;
for (const key of CONTROLLER_DISCRETE_BINDING_KEYS) {
const bindingValue = source[key];
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_BUTTON_BINDINGS.includes(
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
)
) {
target[key] = resolveLegacyDiscreteBinding(
legacyValue as ControllerButtonBinding,
buttonIndices,
);
continue;
}
const parsedObject = parseDiscreteBindingObject(bindingValue);
if (parsedObject) {
target[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`${pathPrefix}.${key}`,
bindingValue,
target[key],
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
);
}
}
for (const key of CONTROLLER_AXIS_BINDING_KEYS) {
const bindingValue = source[key];
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number])
) {
target[key] = resolveLegacyAxisBinding(legacyValue as ControllerAxisBinding, key);
continue;
}
if (legacyValue === 'none') {
target[key] = { kind: 'none' };
continue;
}
const parsedObject = parseAxisBindingObject(bindingValue, key);
if (parsedObject) {
target[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`${pathPrefix}.${key}`,
bindingValue,
target[key],
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
);
}
}
}
export function applyControllerConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (!isObject(src.controller)) return;
const enabled = asBoolean(src.controller.enabled);
if (enabled !== undefined) {
resolved.controller.enabled = enabled;
} else if (src.controller.enabled !== undefined) {
warn(
'controller.enabled',
src.controller.enabled,
resolved.controller.enabled,
'Expected boolean.',
);
}
const preferredGamepadId = asString(src.controller.preferredGamepadId);
if (preferredGamepadId !== undefined) {
resolved.controller.preferredGamepadId = preferredGamepadId;
}
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
if (preferredGamepadLabel !== undefined) {
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
}
const smoothScroll = asBoolean(src.controller.smoothScroll);
if (smoothScroll !== undefined) {
resolved.controller.smoothScroll = smoothScroll;
} else if (src.controller.smoothScroll !== undefined) {
warn(
'controller.smoothScroll',
src.controller.smoothScroll,
resolved.controller.smoothScroll,
'Expected boolean.',
);
}
const triggerInputMode = asString(src.controller.triggerInputMode);
if (
triggerInputMode === 'auto' ||
triggerInputMode === 'digital' ||
triggerInputMode === 'analog'
) {
resolved.controller.triggerInputMode = triggerInputMode;
} else if (src.controller.triggerInputMode !== undefined) {
warn(
'controller.triggerInputMode',
src.controller.triggerInputMode,
resolved.controller.triggerInputMode,
"Expected 'auto', 'digital', or 'analog'.",
);
}
const boundedNumberKeys = [
'scrollPixelsPerSecond',
'horizontalJumpPixels',
'repeatDelayMs',
'repeatIntervalMs',
] as const;
for (const key of boundedNumberKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && Math.floor(value) > 0) {
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected positive number.',
);
}
}
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
for (const key of deadzoneKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && value >= 0 && value <= 1) {
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected number between 0 and 1.',
);
}
}
applyControllerButtonIndices(
src.controller.buttonIndices,
resolved.controller.buttonIndices,
'controller.buttonIndices',
warn,
);
applyControllerBindings(
src.controller.bindings,
resolved.controller.bindings,
resolved.controller.buttonIndices,
'controller.bindings',
warn,
);
if (isObject(src.controller.profiles)) {
for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) {
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) {
warn(
`controller.profiles.${profileId}`,
rawProfile,
undefined,
'Reserved profile id is not allowed.',
);
continue;
}
if (!isObject(rawProfile)) {
warn(
`controller.profiles.${profileId}`,
rawProfile,
undefined,
'Expected controller profile object.',
);
continue;
}
const label = asString(rawProfile.label);
if (rawProfile.label !== undefined && label === undefined) {
warn(
`controller.profiles.${profileId}.label`,
rawProfile.label,
profileId,
'Expected string.',
);
}
const profile = {
label: label ?? profileId,
buttonIndices: structuredClone(resolved.controller.buttonIndices),
bindings: structuredClone(resolved.controller.bindings),
};
applyControllerButtonIndices(
rawProfile.buttonIndices,
profile.buttonIndices,
`controller.profiles.${profileId}.buttonIndices`,
warn,
);
applyControllerBindings(
rawProfile.bindings,
profile.bindings,
profile.buttonIndices,
`controller.profiles.${profileId}.bindings`,
warn,
);
resolved.controller.profiles[profileId] = profile;
}
}
}
+58 -341
View File
@@ -1,150 +1,7 @@
import type {
ControllerAxisBinding,
ControllerAxisBindingConfig,
ControllerAxisDirection,
ControllerButtonBinding,
ControllerButtonIndicesConfig,
ControllerDpadFallback,
ControllerDiscreteBindingConfig,
ResolvedControllerAxisBinding,
ResolvedControllerDiscreteBinding,
} from '../../types/runtime';
import { ResolveContext } from './context';
import { applyControllerConfig } from './controller';
import { asBoolean, asNumber, asString, isObject } from './shared';
const CONTROLLER_BUTTON_BINDINGS = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const CONTROLLER_AXIS_BINDINGS = [
'leftStickX',
'leftStickY',
'rightStickX',
'rightStickY',
] as const;
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
leftStickX: 0,
leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
};
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
Exclude<ControllerButtonBinding, 'none'>,
keyof Required<ControllerButtonIndicesConfig>
> = {
select: 'select',
buttonSouth: 'buttonSouth',
buttonEast: 'buttonEast',
buttonNorth: 'buttonNorth',
buttonWest: 'buttonWest',
leftShoulder: 'leftShoulder',
rightShoulder: 'rightShoulder',
leftStickPress: 'leftStickPress',
rightStickPress: 'rightStickPress',
leftTrigger: 'leftTrigger',
rightTrigger: 'rightTrigger',
};
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
leftStickHorizontal: 'horizontal',
leftStickVertical: 'vertical',
rightStickHorizontal: 'none',
rightStickVertical: 'none',
} as const satisfies Record<string, ControllerDpadFallback>;
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
return value === 'negative' || value === 'positive';
}
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
return value === 'none' || value === 'horizontal' || value === 'vertical';
}
function resolveLegacyDiscreteBinding(
value: ControllerButtonBinding,
buttonIndices: Required<ControllerButtonIndicesConfig>,
): ResolvedControllerDiscreteBinding {
if (value === 'none') {
return { kind: 'none' };
}
return {
kind: 'button',
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
};
}
function resolveLegacyAxisBinding(
value: ControllerAxisBinding,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding {
return {
kind: 'axis',
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
if (!isObject(value) || typeof value.kind !== 'string') return null;
if (value.kind === 'none') {
return { kind: 'none' };
}
if (value.kind === 'button') {
return typeof value.buttonIndex === 'number' &&
Number.isInteger(value.buttonIndex) &&
value.buttonIndex >= 0
? { kind: 'button', buttonIndex: value.buttonIndex }
: null;
}
if (value.kind === 'axis') {
return typeof value.axisIndex === 'number' &&
Number.isInteger(value.axisIndex) &&
value.axisIndex >= 0 &&
isControllerAxisDirection(value.direction)
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
: null;
}
return null;
}
function parseAxisBindingObject(
value: unknown,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding | null {
if (isObject(value) && value.kind === 'none') {
return { kind: 'none' };
}
if (!isObject(value) || value.kind !== 'axis') return null;
if (
typeof value.axisIndex !== 'number' ||
!Number.isInteger(value.axisIndex) ||
value.axisIndex < 0
) {
return null;
}
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
return null;
}
return {
kind: 'axis',
axisIndex: value.axisIndex,
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
export function applyCoreDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
@@ -245,203 +102,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.controller)) {
const enabled = asBoolean(src.controller.enabled);
if (enabled !== undefined) {
resolved.controller.enabled = enabled;
} else if (src.controller.enabled !== undefined) {
warn(
'controller.enabled',
src.controller.enabled,
resolved.controller.enabled,
'Expected boolean.',
);
}
const preferredGamepadId = asString(src.controller.preferredGamepadId);
if (preferredGamepadId !== undefined) {
resolved.controller.preferredGamepadId = preferredGamepadId;
}
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
if (preferredGamepadLabel !== undefined) {
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
}
const smoothScroll = asBoolean(src.controller.smoothScroll);
if (smoothScroll !== undefined) {
resolved.controller.smoothScroll = smoothScroll;
} else if (src.controller.smoothScroll !== undefined) {
warn(
'controller.smoothScroll',
src.controller.smoothScroll,
resolved.controller.smoothScroll,
'Expected boolean.',
);
}
const triggerInputMode = asString(src.controller.triggerInputMode);
if (
triggerInputMode === 'auto' ||
triggerInputMode === 'digital' ||
triggerInputMode === 'analog'
) {
resolved.controller.triggerInputMode = triggerInputMode;
} else if (src.controller.triggerInputMode !== undefined) {
warn(
'controller.triggerInputMode',
src.controller.triggerInputMode,
resolved.controller.triggerInputMode,
"Expected 'auto', 'digital', or 'analog'.",
);
}
const boundedNumberKeys = [
'scrollPixelsPerSecond',
'horizontalJumpPixels',
'repeatDelayMs',
'repeatIntervalMs',
] as const;
for (const key of boundedNumberKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && Math.floor(value) > 0) {
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected positive number.',
);
}
}
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
for (const key of deadzoneKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && value >= 0 && value <= 1) {
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected number between 0 and 1.',
);
}
}
if (isObject(src.controller.buttonIndices)) {
const buttonIndexKeys = [
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
for (const key of buttonIndexKeys) {
const value = asNumber(src.controller.buttonIndices[key]);
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
resolved.controller.buttonIndices[key] = value;
} else if (src.controller.buttonIndices[key] !== undefined) {
warn(
`controller.buttonIndices.${key}`,
src.controller.buttonIndices[key],
resolved.controller.buttonIndices[key],
'Expected non-negative integer.',
);
}
}
}
if (isObject(src.controller.bindings)) {
const buttonBindingKeys = [
'toggleLookup',
'closeLookup',
'toggleKeyboardOnlyMode',
'mineCard',
'quitMpv',
'previousAudio',
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
] as const;
for (const key of buttonBindingKeys) {
const bindingValue = src.controller.bindings[key];
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_BUTTON_BINDINGS.includes(
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
)
) {
resolved.controller.bindings[key] = resolveLegacyDiscreteBinding(
legacyValue as ControllerButtonBinding,
resolved.controller.buttonIndices,
);
continue;
}
const parsedObject = parseDiscreteBindingObject(bindingValue);
if (parsedObject) {
resolved.controller.bindings[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`controller.bindings.${key}`,
bindingValue,
resolved.controller.bindings[key],
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
);
}
}
const axisBindingKeys = [
'leftStickHorizontal',
'leftStickVertical',
'rightStickHorizontal',
'rightStickVertical',
] as const;
for (const key of axisBindingKeys) {
const bindingValue = src.controller.bindings[key];
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_AXIS_BINDINGS.includes(
legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number],
)
) {
resolved.controller.bindings[key] = resolveLegacyAxisBinding(
legacyValue as ControllerAxisBinding,
key,
);
continue;
}
if (legacyValue === 'none') {
resolved.controller.bindings[key] = { kind: 'none' };
continue;
}
const parsedObject = parseAxisBindingObject(bindingValue, key);
if (parsedObject) {
resolved.controller.bindings[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`controller.bindings.${key}`,
bindingValue,
resolved.controller.bindings[key],
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
);
}
}
}
}
applyControllerConfig(context);
if (Array.isArray(src.keybindings)) {
resolved.keybindings = src.keybindings.filter(
@@ -478,6 +139,62 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.updates)) {
const enabled = asBoolean(src.updates.enabled);
if (enabled !== undefined) {
resolved.updates.enabled = enabled;
} else if (src.updates.enabled !== undefined) {
warn('updates.enabled', src.updates.enabled, resolved.updates.enabled, 'Expected boolean.');
}
const checkIntervalHours = asNumber(src.updates.checkIntervalHours);
if (
checkIntervalHours !== undefined &&
Number.isFinite(checkIntervalHours) &&
checkIntervalHours > 0
) {
resolved.updates.checkIntervalHours = checkIntervalHours;
} else if (src.updates.checkIntervalHours !== undefined) {
warn(
'updates.checkIntervalHours',
src.updates.checkIntervalHours,
resolved.updates.checkIntervalHours,
'Expected positive number.',
);
}
const notificationType = asString(src.updates.notificationType);
if (
notificationType === 'system' ||
notificationType === 'osd' ||
notificationType === 'both' ||
notificationType === 'none'
) {
resolved.updates.notificationType = notificationType;
} else if (src.updates.notificationType !== undefined) {
warn(
'updates.notificationType',
src.updates.notificationType,
resolved.updates.notificationType,
'Expected system, osd, both, or none.',
);
}
const channel = asString(src.updates.channel);
if (channel === 'stable' || channel === 'prerelease') {
resolved.updates.channel = channel;
} else if (src.updates.channel !== undefined) {
warn(
'updates.channel',
src.updates.channel,
resolved.updates.channel,
'Expected stable or prerelease.',
);
}
} else if (src.updates !== undefined) {
warn('updates', src.updates, resolved.updates, 'Expected object.');
}
if (isObject(src.shortcuts)) {
const shortcutKeys = [
'toggleVisibleOverlayGlobal',
@@ -32,10 +32,16 @@ test('anilist update queue enqueues, snapshots, and dequeues success', () => {
const loggerState = createLogger();
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
queue.enqueue('k1', 'Demo', 1);
queue.enqueue('k1', 'Demo', 1, 2);
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 });
assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1');
assert.deepEqual(
{
key: queue.nextReady(Number.MAX_SAFE_INTEGER)?.key,
season: queue.nextReady(Number.MAX_SAFE_INTEGER)?.season,
},
{ key: 'k1', season: 2 },
);
queue.markSuccess('k1');
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
@@ -9,6 +9,7 @@ const MAX_ITEMS = 500;
export interface AnilistQueuedUpdate {
key: string;
title: string;
season?: number | null;
episode: number;
createdAt: number;
attemptCount: number;
@@ -28,7 +29,7 @@ export interface AnilistRetryQueueSnapshot {
}
export interface AnilistUpdateQueue {
enqueue: (key: string, title: string, episode: number) => void;
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
markSuccess: (key: string) => void;
markFailure: (key: string, reason: string, nowMs?: number) => void;
@@ -106,7 +107,7 @@ export function createAnilistUpdateQueue(
load();
return {
enqueue(key: string, title: string, episode: number): void {
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
const existing = pending.find((item) => item.key === key);
if (existing) {
return;
@@ -117,6 +118,7 @@ export function createAnilistUpdateQueue(
pending.push({
key,
title,
season,
episode,
createdAt: Date.now(),
attemptCount: 0,
@@ -265,6 +265,125 @@ test('updateAnilistPostWatchProgress skips when progress already reached', async
}
});
test('updateAnilistPostWatchProgress returns non-retryable error when media is not planning or watching', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 33, episodes: 12, title: { english: 'Missing Show' } }],
},
},
});
}
return createJsonResponse({
data: {
Media: { id: 33, mediaListEntry: null },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Missing Show', 2);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /not in your AniList Planning or Watching list/i);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress prefers season-specific AniList matches', async () => {
const originalFetch = globalThis.fetch;
const searchTerms: string[] = [];
let call = 0;
globalThis.fetch = (async (_input, init) => {
call += 1;
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
if (call === 1) {
searchTerms.push(String(body.variables?.search));
return createJsonResponse({
data: {
Page: {
media: [
{ id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } },
{ id: 101, episodes: 12, title: { english: 'Demo Show' } },
],
},
},
});
}
if (call === 2) {
assert.equal(body.variables?.mediaId, 202);
return createJsonResponse({
data: {
Media: { id: 202, mediaListEntry: null },
},
});
}
return createJsonResponse({
data: {
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
season: 2,
});
assert.deepEqual(searchTerms, ['Demo Show Season 2']);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /not in your AniList Planning or Watching list/i);
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 44, episodes: 12, title: { english: 'Rewatch Show' } }],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: { id: 44, mediaListEntry: { progress: 0, status: 'REPEATING' } },
},
});
}
return createJsonResponse({
data: {
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Rewatch Show', 2);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /marked repeating on AniList/i);
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress returns error when search fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
+77 -21
View File
@@ -18,10 +18,12 @@ export interface AnilistMediaGuess {
export interface AnilistPostWatchUpdateResult {
status: 'updated' | 'skipped' | 'error';
message: string;
retryable?: boolean;
}
export interface AnilistPostWatchUpdateOptions {
rateLimiter?: AnilistRateLimiter;
season?: number | null;
}
interface AnilistGraphQlError {
@@ -156,6 +158,28 @@ function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, ' ');
}
function titleMentionsSeason(title: string, season: number): boolean {
const normalized = normalizeTitle(title);
return (
normalized.includes(`season ${season}`) ||
normalized.includes(`s${String(season).padStart(2, '0')}`) ||
normalized.includes(`s${season}`)
);
}
function buildSearchCandidates(title: string, season: number | null | undefined): string[] {
const trimmed = title.trim();
if (!trimmed) return [];
const candidates =
typeof season === 'number' &&
Number.isInteger(season) &&
season > 1 &&
!titleMentionsSeason(trimmed, season)
? [`${trimmed} Season ${season}`, trimmed]
: [trimmed];
return candidates.filter((candidate, index, all) => all.indexOf(candidate) === index);
}
async function anilistGraphQl<T>(
accessToken: string,
query: string,
@@ -226,6 +250,15 @@ function pickBestSearchResult(
return { id: selected.id, title: selectedTitle };
}
function isUpdateableListStatus(status: string | null | undefined): boolean {
return status === 'CURRENT' || status === 'PLANNING';
}
function formatListStatus(status: string | null | undefined): string {
if (!status) return 'not in your AniList Planning or Watching list';
return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`;
}
export async function guessAnilistMediaInfo(
mediaPath: string | null,
mediaTitle: string | null,
@@ -279,27 +312,42 @@ export async function updateAnilistPostWatchProgress(
episode: number,
options: AnilistPostWatchUpdateOptions = {},
): Promise<AnilistPostWatchUpdateResult> {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
accessToken,
`
query ($search: String!) {
Page(perPage: 5) {
media(search: $search, type: ANIME) {
id
episodes
title {
romaji
english
native
let media: NonNullable<NonNullable<AnilistSearchData['Page']>['media']> = [];
let searchError: string | null = null;
let pickTitle = title;
const searchCandidates = buildSearchCandidates(title, options.season);
for (const search of searchCandidates) {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
accessToken,
`
query ($search: String!) {
Page(perPage: 5) {
media(search: $search, type: ANIME) {
id
episodes
title {
romaji
english
native
}
}
}
}
}
`,
{ search: title },
options,
);
const searchError = firstErrorMessage(searchResponse);
`,
{ search },
options,
);
searchError = firstErrorMessage(searchResponse);
if (searchError) {
break;
}
media = searchResponse.data?.Page?.media ?? [];
if (media.length > 0) {
pickTitle = search;
break;
}
}
if (searchError) {
return {
status: 'error',
@@ -307,8 +355,7 @@ export async function updateAnilistPostWatchProgress(
};
}
const media = searchResponse.data?.Page?.media ?? [];
const picked = pickBestSearchResult(title, episode, media);
const picked = pickBestSearchResult(pickTitle, episode, media);
if (!picked) {
return { status: 'error', message: 'AniList search returned no matches.' };
}
@@ -337,7 +384,16 @@ export async function updateAnilistPostWatchProgress(
};
}
const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
const entry = entryResponse.data?.Media?.mediaListEntry ?? null;
if (!entry || !isUpdateableListStatus(entry.status)) {
return {
status: 'error',
retryable: false,
message: `AniList update not possible: "${picked.title}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
};
}
const currentProgress = entry.progress ?? 0;
if (typeof currentProgress === 'number' && currentProgress >= episode) {
return {
status: 'skipped',
+50 -4
View File
@@ -70,6 +70,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
texthooker: false,
texthookerOpenBrowser: false,
help: false,
update: false,
updateLauncherPath: undefined,
autoStartOverlay: false,
generateConfig: false,
backupOverwrite: false,
@@ -128,8 +130,8 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
},
openFirstRunSetup: () => {
calls.push('openFirstRunSetup');
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
},
setVisibleOverlayVisible: (visible) => {
calls.push(`setVisibleOverlayVisible:${visible}`);
@@ -231,6 +233,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
runYoutubePlaybackFlow: async (request) => {
calls.push(`runYoutubePlaybackFlow:${request.url}:${request.mode}:${request.source}`);
},
runUpdateCommand: async (args) => {
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
},
printHelp: () => {
calls.push('printHelp');
},
@@ -242,6 +247,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
log: (message) => {
calls.push(`log:${message}`);
},
logDebug: (message) => {
calls.push(`debug:${message}`);
},
warn: (message) => {
calls.push(`warn:${message}`);
},
@@ -353,16 +361,54 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
);
});
test('handleCliCommand forces setup open for second-instance setup command', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ setup: true }), 'second-instance', deps);
assert.ok(calls.includes('openFirstRunSetup:force'));
assert.ok(calls.includes('debug:Opened first-run setup flow.'));
});
test('handleCliCommand opens first-run setup window for --setup', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ setup: true }), 'initial', deps);
assert.ok(calls.includes('openFirstRunSetup'));
assert.ok(calls.includes('log:Opened first-run setup flow.'));
assert.ok(calls.includes('openFirstRunSetup:force'));
assert.ok(calls.includes('debug:Opened first-run setup flow.'));
assert.equal(calls.includes('log:Opened first-run setup flow.'), false);
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
});
test('handleCliCommand runs update command without overlay startup', async () => {
const { deps, calls } = createDeps();
handleCliCommand(
makeArgs({ update: true, updateLauncherPath: '/home/kyle/.local/bin/subminer' }),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ['runUpdateCommand:/home/kyle/.local/bin/subminer']);
});
test('handleCliCommand stops app after headless initial update completes', async () => {
const { deps, calls } = createDeps({
hasMainWindow: () => false,
});
handleCliCommand(
makeArgs({ update: true, updateLauncherPath: '/home/kyle/.local/bin/subminer' }),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, ['runUpdateCommand:/home/kyle/.local/bin/subminer', 'stopApp']);
});
test('handleCliCommand dispatches stats command without overlay startup', async () => {
const { deps, calls } = createDeps({
runStatsCommand: async () => {
+23 -4
View File
@@ -41,7 +41,7 @@ export interface CliCommandServiceDeps {
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void;
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
@@ -95,6 +95,7 @@ export interface CliCommandServiceDeps {
}) => Promise<CharacterDictionarySelectionResult>;
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
@@ -105,6 +106,7 @@ export interface CliCommandServiceDeps {
getMultiCopyTimeoutMs: () => number;
showMpvOsd: (text: string) => void;
log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
@@ -156,7 +158,7 @@ interface MiningCliRuntime {
}
interface UiCliRuntime {
openFirstRunSetup: () => void;
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
@@ -174,6 +176,7 @@ interface AnilistCliRuntime {
interface AppCliRuntime {
stop: () => void;
hasMainWindow: () => boolean;
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
}
@@ -209,6 +212,7 @@ export interface CliCommandDepsRuntimeOptions {
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => unknown;
log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
@@ -277,12 +281,14 @@ export function createCliCommandDepsRuntime(
setCharacterDictionarySelection: options.dictionary.setSelection,
runStatsCommand: options.jellyfin.runStatsCommand,
runJellyfinCommand: options.jellyfin.runCommand,
runUpdateCommand: options.app.runUpdateCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow,
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
showMpvOsd: options.mpv.showOsd,
log: options.log,
logDebug: options.logDebug,
warn: options.warn,
error: options.error,
};
@@ -375,8 +381,8 @@ export function handleCliCommand(
} else if (args.togglePrimarySubtitleBar) {
deps.togglePrimarySubtitleBar();
} else if (args.setup) {
deps.openFirstRunSetup();
deps.log('Opened first-run setup flow.');
deps.openFirstRunSetup(true);
deps.logDebug('Opened first-run setup flow.');
} else if (args.settings) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.show || args.showVisibleOverlay) {
@@ -416,6 +422,19 @@ export function handleCliCommand(
deps.stopApp();
}
});
} else if (args.update) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps
.runUpdateCommand(args, source)
.catch((err) => {
deps.error('runUpdateCommand failed:', err);
deps.showMpvOsd(`Update failed: ${(err as Error).message}`);
})
.finally(() => {
if (shouldStopAfterRun) {
deps.stopApp();
}
});
} else if (args.toggleSecondarySub) {
deps.cycleSecondarySubMode();
} else if (args.triggerFieldGrouping) {
+1 -1
View File
@@ -27,7 +27,7 @@ export {
isAutoUpdateEnabledRuntime,
shouldAutoInitializeOverlayRuntimeFromConfig,
} from './startup';
export { openYomitanSettingsWindow } from './yomitan-settings';
export { destroyYomitanSettingsWindow, openYomitanSettingsWindow } from './yomitan-settings';
export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer';
export {
addYomitanNoteViaSearch,
+85 -1
View File
@@ -83,6 +83,7 @@ function createControllerConfigFixture() {
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
},
profiles: {},
};
}
@@ -218,6 +219,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
onOverlayModalClosed: () => {},
onOverlayMouseInteractionChanged: (active) => {
calls.push(`overlay-interaction:${active}`);
},
openYomitanSettings: () => {},
quitApp: () => {},
toggleVisibleOverlay: () => {},
@@ -281,6 +285,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
deps.clearAnilistToken();
deps.openAnilistSetup();
deps.onOverlayMouseInteractionChanged?.(true, null);
assert.deepEqual(deps.getAnilistQueueStatus(), {
pending: 1,
ready: 0,
@@ -298,10 +303,37 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
assert.deepEqual(calls, [
'clearAnilistToken',
'openAnilistSetup',
'overlay-interaction:true',
'retryAnilistQueueNow',
]);
assert.equal(deps.getPlaybackPaused(), true);
});
test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction active state', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
onOverlayMouseInteractionChanged: (active) => {
calls.push(`overlay-interaction:${active}`);
},
}),
registrar,
);
const handler = handlers.on.get(IPC_CHANNELS.command.setIgnoreMouseEvents);
assert.equal(typeof handler, 'function');
handler?.({}, true, { forward: true });
handler?.({}, false, {});
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
});
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -944,6 +976,58 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
]);
});
test('registerIpcHandlers accepts per-controller profile config updates', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const controllerSaves: unknown[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
saveControllerConfig: async (update) => {
controllerSaves.push(update);
},
}),
registrar,
);
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerConfig);
assert.ok(saveHandler);
const update = {
profiles: {
'pad-1': {
label: 'Pad One',
buttonIndices: {
buttonSouth: 11,
},
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
leftStickHorizontal: { kind: 'axis', axisIndex: 6, dpadFallback: 'horizontal' },
},
},
},
};
await saveHandler({}, update);
assert.deepEqual(controllerSaves, [update]);
await assert.rejects(async () => {
await saveHandler(
{},
{
profiles: {
'pad-1': {
bindings: {
toggleLookup: { kind: 'axis', axisIndex: 0 },
},
},
},
},
);
}, /Invalid controller config payload/);
await assert.rejects(async () => {
await saveHandler({}, JSON.parse('{"profiles":{"__proto__":{"label":"polluted"}}}'));
}, /Invalid controller config payload/);
});
test('registerIpcHandlers validates dispatchSessionAction payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const dispatched: SessionActionDispatchRequest[] = [];
+10
View File
@@ -44,6 +44,10 @@ export interface IpcServiceDeps {
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayMouseInteractionChanged?: (
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleDevTools: () => void;
@@ -175,6 +179,10 @@ export interface IpcDepsRuntimeOptions {
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayMouseInteractionChanged?: (
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleVisibleOverlay: () => void;
@@ -233,6 +241,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
return {
onOverlayModalClosed: options.onOverlayModalClosed,
onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
openYomitanSettings: options.openYomitanSettings,
quitApp: options.quitApp,
toggleDevTools: () => {
@@ -349,6 +358,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
}
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
},
);
@@ -547,7 +547,7 @@ test('initializeOverlayRuntime hides overlay windows when tracker loses the targ
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
});
test('initializeOverlayRuntime hides visible overlay on Windows tracker loss when target is not minimized', () => {
test('initializeOverlayRuntime refreshes visible overlay on tracker loss when target is not minimized', () => {
const calls: string[] = [];
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
@@ -600,7 +600,7 @@ test('initializeOverlayRuntime hides visible overlay on Windows tracker loss whe
calls.length = 0;
tracker.onWindowLost?.();
assert.deepEqual(calls, ['hide-visible', 'sync-shortcuts']);
assert.deepEqual(calls, ['update-visible']);
});
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {
+7 -3
View File
@@ -105,10 +105,14 @@ export function initializeOverlayRuntime(options: {
};
windowTracker.onWindowLost = () => {
options.releaseOverlayOwner?.();
for (const window of options.getOverlayWindows()) {
window.hide();
if (windowTracker.isTargetWindowMinimized()) {
for (const window of options.getOverlayWindows()) {
window.hide();
}
options.syncOverlayShortcuts();
return;
}
options.syncOverlayShortcuts();
options.updateVisibleOverlayVisibility();
};
windowTracker.onWindowFocusChange = () => {
if (options.isVisibleOverlayVisible()) {
+445 -11
View File
@@ -42,6 +42,11 @@ function createMainWindowRecorder() {
setAlwaysOnTop: (flag: boolean) => {
calls.push(`always-on-top:${flag}`);
},
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
calls.push(
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
},
@@ -538,11 +543,12 @@ test('forced passthrough still shows tracked overlay while bound to mpv on Windo
assert.ok(calls.includes('sync-windows-z-order'));
});
test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => {
test('forced mouse passthrough keeps macOS tracked overlay above active mpv', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
updateVisibleOverlayVisibility({
@@ -571,8 +577,53 @@ test('forced mouse passthrough drops macOS tracked overlay below higher-priority
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('always-on-top:false'));
});
test('forced mouse passthrough still hides macOS tracked overlay when mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
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');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
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('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
@@ -883,7 +934,7 @@ test('visible overlay stays hidden while a modal window is active', () => {
assert.ok(!calls.includes('update-bounds'));
});
test('macOS tracked visible overlay stays interactive without passively stealing focus', () => {
test('macOS tracked visible overlay starts click-through without passively stealing focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
@@ -915,12 +966,53 @@ test('macOS tracked visible overlay stays interactive without passively stealing
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('macOS keeps active mpv overlay visible and interactive during tracker refresh', () => {
test('macOS tracked visible overlay remains click-through even if the overlay had focus', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
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');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(!calls.includes('focus'));
});
test('macOS keeps active mpv overlay visible and click-through during tracker refresh', () => {
const { window, calls } = createMainWindowRecorder();
const osdMessages: string[] = [];
const tracker: WindowTrackerStub = {
@@ -961,7 +1053,7 @@ test('macOS keeps active mpv overlay visible and interactive during tracker refr
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
@@ -969,7 +1061,7 @@ test('macOS keeps active mpv overlay visible and interactive during tracker refr
assert.deepEqual(osdMessages, []);
});
test('macOS tracked overlay releases topmost level when mpv loses foreground', () => {
test('macOS tracked overlay hides when mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
@@ -977,6 +1069,9 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
@@ -1006,14 +1101,202 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('show'));
});
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('hide'));
});
test('macOS lets an active overlay receive mouse input instead of forcing passthrough', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(!calls.includes('mouse-ignore:true:forward'));
assert.ok(!calls.includes('hide'));
});
test('macOS focuses an active overlay so lookup trigger keys reach it', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('focus'));
assert.ok(!calls.includes('hide'));
});
test('macOS tracked overlay passively reappears when mpv regains foreground', () => {
const { window, calls } = createMainWindowRecorder();
let targetFocused = false;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => targetFocused,
};
window.show();
calls.length = 0;
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
run();
assert.ok(calls.includes('hide'));
calls.length = 0;
targetFocused = true;
run();
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
const { window, calls } = createMainWindowRecorder();
const osdMessages: string[] = [];
@@ -1060,7 +1343,7 @@ test('macOS preserves an already visible active mpv overlay while tracker is tem
assert.equal(trackerWarning, false);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('hide'));
@@ -1101,7 +1384,8 @@ test('forced mouse passthrough keeps macOS tracked overlay passive while visible
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
@@ -1390,7 +1674,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
assert.deepEqual(osdMessages, []);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
@@ -1398,6 +1682,156 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
assert.ok(!calls.includes('show'));
});
test('macOS hides visible overlay during tracker loss after mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
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');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {
calls.push('loading-osd');
},
} as never);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('loading-osd'));
});
test('macOS keeps a focused overlay visible during tracker loss', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
setFocused(true);
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');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {
calls.push('loading-osd');
},
} as never);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('loading-osd'));
});
test('macOS keeps an interactive overlay visible during tracker loss even when Electron focus drops', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {
calls.push('loading-osd');
},
} as never);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('loading-osd'));
});
test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => {
const { window } = createMainWindowRecorder();
const osdMessages: string[] = [];
+84 -16
View File
@@ -15,6 +15,17 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
opacityCapableWindow.setOpacity?.(opacity);
}
function releaseOverlayWindowLevel(window: BrowserWindow): void {
window.setAlwaysOnTop(false);
const allWorkspacesWindow = window as BrowserWindow & {
setVisibleOnAllWorkspaces?: (
visible: boolean,
options?: { visibleOnFullScreen?: boolean },
) => void;
};
allWorkspacesWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
}
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
if (!pendingTimeout) {
@@ -52,6 +63,7 @@ export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
modalActive?: boolean;
forceMousePassthrough?: boolean;
overlayInteractionActive?: boolean;
mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null;
lastKnownWindowsForegroundProcessName?: string | null;
@@ -78,6 +90,7 @@ export function updateVisibleOverlayVisibility(args: {
}
const mainWindow = args.mainWindow;
const overlayInteractionActive = args.overlayInteractionActive === true;
if (args.modalActive) {
if (args.isWindowsPlatform) {
@@ -93,16 +106,34 @@ export function updateVisibleOverlayVisibility(args: {
const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible();
const isVisibleOverlayFocused =
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
overlayInteractionActive ||
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
const windowTracker = args.windowTracker;
const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
const isTrackedMacOSTargetMinimized =
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
const hasTransientMacOSTrackerLoss =
args.isMacOSPlatform &&
canReportMacOSTargetMinimized &&
!!windowTracker &&
!windowTracker.isTracking() &&
!isTrackedMacOSTargetMinimized &&
trackedMacOSTargetFocused !== false &&
mainWindow.isVisible();
const isTrackedMacOSTargetFocused =
!args.isMacOSPlatform || !args.windowTracker
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
? true
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
: (trackedMacOSTargetFocused ?? true);
const shouldReleaseMacOSOverlayLevel =
args.isMacOSPlatform &&
!!args.windowTracker &&
!hasTransientMacOSTrackerLoss &&
!isVisibleOverlayFocused &&
!isTrackedMacOSTargetFocused;
// Renderer hover tracking temporarily disables this for subtitle and popup interaction.
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive;
const shouldDefaultToPassthrough =
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
const windowsForegroundProcessName =
@@ -127,6 +158,7 @@ export function updateVisibleOverlayVisibility(args: {
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
const shouldIgnoreMouseEvents =
shouldUseMacOSMousePassthrough ||
forceMousePassthrough ||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
@@ -143,14 +175,22 @@ export function updateVisibleOverlayVisibility(args: {
mainWindow.setIgnoreMouseEvents(false);
}
if (shouldReleaseMacOSOverlayLevel) {
releaseOverlayWindowLevel(mainWindow);
if (wasVisible) {
mainWindow.hide();
}
return false;
}
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 && !shouldReleaseMacOSOverlayLevel) {
} else if (!forceMousePassthrough || args.isMacOSPlatform) {
args.ensureOverlayWindowLevel(mainWindow);
} else {
mainWindow.setAlwaysOnTop(false);
releaseOverlayWindowLevel(mainWindow);
}
if (!wasVisible) {
const hasWebContents =
@@ -163,16 +203,20 @@ export function updateVisibleOverlayVisibility(args: {
// 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);
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
scheduleWindowsOverlayReveal(
mainWindow,
shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined,
);
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal(
mainWindow,
shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined,
);
}
} else {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
@@ -193,6 +237,16 @@ export function updateVisibleOverlayVisibility(args: {
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
}
if (
args.isMacOSPlatform &&
overlayInteractionActive &&
!forceMousePassthrough &&
typeof mainWindow.isFocused === 'function' &&
!mainWindow.isFocused()
) {
mainWindow.focus();
}
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
mainWindow.focus();
}
@@ -200,6 +254,11 @@ export function updateVisibleOverlayVisibility(args: {
return !shouldReleaseMacOSOverlayLevel;
};
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
shouldEnforceLayerOrder &&
!args.isWindowsPlatform &&
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
const maybeShowOverlayLoadingOsd = (): void => {
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
return;
@@ -242,7 +301,7 @@ export function updateVisibleOverlayVisibility(args: {
}
args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts();
@@ -274,9 +333,18 @@ export function updateVisibleOverlayVisibility(args: {
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
const hasActiveMacOSTargetSignal =
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
const hasActiveMacOSOverlaySignal = args.isMacOSPlatform && overlayInteractionActive;
const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
const isTrackedMacOSTargetMinimized =
canReportMacOSTargetMinimized && args.windowTracker.isTargetWindowMinimized();
const shouldPreserveTransientTrackedOverlay =
(args.isMacOSPlatform &&
(hasRetainedTrackedGeometry || (mainWindow.isVisible() && hasActiveMacOSTargetSignal))) ||
!isTrackedMacOSTargetMinimized &&
(hasRetainedTrackedGeometry ||
(mainWindow.isVisible() && hasActiveMacOSOverlaySignal) ||
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
(args.isWindowsPlatform &&
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
!args.windowTracker.isTargetWindowMinimized());
@@ -292,7 +360,7 @@ export function updateVisibleOverlayVisibility(args: {
}
args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts();
+3 -2
View File
@@ -66,15 +66,16 @@ export function handleOverlayWindowBlurred(options: {
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
ensureOverlayWindowLevel: () => void;
moveWindowTop: () => void;
onWindowsVisibleOverlayBlur?: () => void;
onVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform;
}): boolean {
const platform = options.platform ?? process.platform;
if (platform === 'win32' && options.kind === 'visible') {
options.onWindowsVisibleOverlayBlur?.();
options.onVisibleOverlayBlur?.();
return false;
}
if (platform === 'darwin' && options.kind === 'visible') {
options.onVisibleOverlayBlur?.();
return false;
}
+7 -7
View File
@@ -136,14 +136,14 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
moveWindowTop: () => {
calls.push('move-top');
},
onWindowsVisibleOverlayBlur: () => {
calls.push('windows-visible-blur');
onVisibleOverlayBlur: () => {
calls.push('visible-blur');
},
platform: 'win32',
});
assert.equal(handled, false);
assert.deepEqual(calls, ['windows-visible-blur']);
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
@@ -166,7 +166,7 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visible overlay blur', () => {
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
@@ -179,14 +179,14 @@ test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visib
moveWindowTop: () => {
calls.push('move-top');
},
onWindowsVisibleOverlayBlur: () => {
calls.push('windows-visible-blur');
onVisibleOverlayBlur: () => {
calls.push('visible-blur');
},
platform: 'darwin',
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
+1 -1
View File
@@ -180,7 +180,7 @@ export function createOverlayWindow(
moveWindowTop: () => {
window.moveTop();
},
onWindowsVisibleOverlayBlur:
onVisibleOverlayBlur:
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
});
});
@@ -223,3 +223,23 @@ test('runStartupBootstrapRuntime enables quiet background mode by default', () =
assert.equal(result.backgroundMode, true);
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
});
test('runStartupBootstrapRuntime enables quiet update mode by default', () => {
const calls: string[] = [];
const args = makeArgs({ update: true });
const result = runStartupBootstrapRuntime({
argv: ['node', 'main.ts', '--update'],
parseArgs: () => args,
setLogLevel: (level, source) => calls.push(`setLog:${level}:${source}`),
forceX11Backend: () => calls.push('forceX11'),
enforceUnsupportedWaylandMode: () => calls.push('enforceWayland'),
getDefaultSocketPath: () => '/tmp/default.sock',
defaultTexthookerPort: 5174,
runGenerateConfigFlow: () => false,
startAppLifecycle: () => calls.push('startLifecycle'),
});
assert.equal(result.backgroundMode, false);
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
});
+86
View File
@@ -358,3 +358,89 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
assert.ok(calls.indexOf('init-overlay') !== -1);
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
});
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
const calls: string[] = [];
await runAppReadyRuntime({
ensureDefaultConfigBootstrap: () => {
calls.push('bootstrap');
},
loadSubtitlePosition: () => {
calls.push('load-subtitle-position');
},
resolveKeybindings: () => {
calls.push('resolve-keybindings');
},
createMpvClient: () => {
calls.push('create-mpv');
},
reloadConfig: () => {
calls.push('reload-config');
},
getResolvedConfig: () => ({
websocket: { enabled: false },
annotationWebsocket: { enabled: false },
texthooker: { launchAtStartup: false },
}),
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {
calls.push('set-log-level');
},
initRuntimeOptionsManager: () => {
calls.push('init-runtime-options');
},
setSecondarySubMode: () => {
calls.push('set-secondary-sub-mode');
},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 0,
defaultAnnotationWebsocketPort: 0,
defaultTexthookerPort: 0,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {
calls.push('log');
},
createMecabTokenizerAndCheck: async () => {},
createSubtitleTimingTracker: () => {
calls.push('subtitle-timing');
},
createImmersionTracker: () => {
calls.push('immersion');
},
startJellyfinRemoteSession: async () => {},
loadYomitanExtension: async () => {
calls.push('load-yomitan-direct');
},
ensureYomitanExtensionLoaded: async () => {
calls.push('load-yomitan-guarded');
},
handleFirstRunSetup: async () => {
calls.push('first-run');
},
prewarmSubtitleDictionaries: async () => {},
startBackgroundWarmups: () => {
calls.push('warmups');
},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {
calls.push('visible-overlay');
},
initializeOverlayRuntime: () => {
calls.push('init-overlay');
},
handleInitialArgs: () => {
calls.push('handle-initial-args');
},
shouldUseMinimalStartup: () => false,
shouldSkipHeavyStartup: () => false,
});
assert.equal(calls.includes('load-yomitan-direct'), false);
assert.equal(calls.includes('load-yomitan-guarded'), true);
});
+8 -13
View File
@@ -44,7 +44,7 @@ export function runStartupBootstrapRuntime(
if (initialArgs.logLevel) {
deps.setLogLevel(initialArgs.logLevel, 'cli');
} else if (initialArgs.background) {
} else if (initialArgs.background || initialArgs.update) {
deps.setLogLevel('warn', 'cli');
}
@@ -131,6 +131,7 @@ export interface AppReadyRuntimeDeps {
createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise<void>;
loadYomitanExtension: () => Promise<void>;
ensureYomitanExtensionLoaded?: () => Promise<void>;
handleFirstRunSetup: () => Promise<void>;
prewarmSubtitleDictionaries?: () => Promise<void>;
startBackgroundWarmups: () => void;
@@ -215,6 +216,8 @@ export function isAutoUpdateEnabledRuntime(
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
const now = deps.now ?? (() => Date.now());
const startupStartedAtMs = now();
const ensureYomitanExtensionReady =
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
deps.ensureDefaultConfigBootstrap();
if (deps.shouldRunHeadlessInitialCommand?.()) {
deps.reloadConfig();
@@ -224,7 +227,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
} else {
deps.createMpvClient();
deps.createSubtitleTimingTracker();
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
deps.initializeOverlayRuntime();
deps.handleInitialArgs();
}
@@ -237,18 +240,10 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
return;
}
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
return;
}
deps.logDebug?.('App-ready critical path started.');
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
@@ -319,12 +314,12 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.');
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
deps.setVisibleOverlayVisible(true);
deps.initializeOverlayRuntime();
} else {
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
}
await deps.handleFirstRunSetup();
+19
View File
@@ -9,6 +9,8 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
Partial<Pick<BrowserWindow, 'showInactive'>>;
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
return (
@@ -104,6 +106,23 @@ export function promoteStatsWindowLevel(
window.moveTop();
}
export function presentStatsWindow(
window: StatsWindowPresentationController,
platform: NodeJS.Platform = process.platform,
): void {
if (platform === 'darwin') {
if (window.showInactive) {
window.showInactive();
} else {
window.show();
}
return;
}
window.show();
window.focus();
}
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
query: Record<string, string>;
} {
+43
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowOptions,
presentStatsWindow,
promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput,
@@ -230,3 +231,45 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () =
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']);
});
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
const calls: string[] = [];
presentStatsWindow(
{
show: () => {
calls.push('show');
},
showInactive: () => {
calls.push('show-inactive');
},
focus: () => {
calls.push('focus');
},
} as never,
'darwin',
);
assert.deepEqual(calls, ['show-inactive']);
});
test('presentStatsWindow shows and focuses on non-macOS platforms', () => {
const calls: string[] = [];
presentStatsWindow(
{
show: () => {
calls.push('show');
},
showInactive: () => {
calls.push('show-inactive');
},
focus: () => {
calls.push('focus');
},
} as never,
'linux',
);
assert.deepEqual(calls, ['show', 'focus']);
});
+2 -2
View File
@@ -5,6 +5,7 @@ import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowOptions,
presentStatsWindow,
promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput,
@@ -49,14 +50,13 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
const bounds = options.resolveBounds();
let placementBounds = syncStatsWindowBounds(window, bounds);
promoteStatsWindowLevel(window);
window.show();
presentStatsWindow(window);
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
if (
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
) {
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
}
window.focus();
options.onVisibilityChanged?.(true);
promoteStatsWindowLevel(window);
}
+103 -4
View File
@@ -9,6 +9,13 @@ const YOMITAN_SYNC_SCRIPT_PATHS = [
path.join('js', 'display', 'display-audio.js'),
];
type ExtensionCopyResult = {
targetDir: string;
copied: boolean;
};
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
function readManifestVersion(manifestPath: string): string | null {
try {
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { version?: unknown };
@@ -18,6 +25,15 @@ function readManifestVersion(manifestPath: string): string | null {
}
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath);
return true;
} catch {
return false;
}
}
export function hashDirectoryContents(dirPath: string): string | null {
try {
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
@@ -53,6 +69,42 @@ export function hashDirectoryContents(dirPath: string): string | null {
}
}
export async function hashDirectoryContentsAsync(dirPath: string): Promise<string | null> {
try {
const dirStat = await fs.promises.stat(dirPath);
if (!dirStat.isDirectory()) {
return null;
}
const hash = createHash('sha256');
const queue = [''];
while (queue.length > 0) {
const relativeDir = queue.shift()!;
const absoluteDir = path.join(dirPath, relativeDir);
const entries = await fs.promises.readdir(absoluteDir, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
const normalizedRelativePath = relativePath.split(path.sep).join('/');
hash.update(normalizedRelativePath);
if (entry.isDirectory()) {
queue.push(relativePath);
continue;
}
if (!entry.isFile()) {
continue;
}
hash.update(await fs.promises.readFile(path.join(dirPath, relativePath)));
}
}
return hash.digest('hex');
} catch {
return null;
}
}
function areFilesEqual(sourcePath: string, targetPath: string): boolean {
if (!fs.existsSync(sourcePath) || !fs.existsSync(targetPath)) return false;
try {
@@ -93,10 +145,7 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
export function ensureExtensionCopy(
sourceDir: string,
userDataPath: string,
): {
targetDir: string;
copied: boolean;
} {
): ExtensionCopyResult {
if (process.platform === 'win32') {
return { targetDir: sourceDir, copied: false };
}
@@ -117,3 +166,53 @@ export function ensureExtensionCopy(
return { targetDir, copied: shouldCopy };
}
export async function ensureExtensionCopyAsync(
sourceDir: string,
userDataPath: string,
): Promise<ExtensionCopyResult> {
if (process.platform === 'win32') {
return { targetDir: sourceDir, copied: false };
}
const extensionsRoot = path.join(userDataPath, 'extensions');
const targetDir = path.join(extensionsRoot, 'yomitan');
const inFlightKey = path.resolve(targetDir);
const inFlight = asyncExtensionCopyInFlight.get(inFlightKey);
if (inFlight) {
return await inFlight;
}
const copyPromise = ensureExtensionCopyAsyncInternal(sourceDir, extensionsRoot, targetDir);
asyncExtensionCopyInFlight.set(inFlightKey, copyPromise);
try {
return await copyPromise;
} finally {
if (asyncExtensionCopyInFlight.get(inFlightKey) === copyPromise) {
asyncExtensionCopyInFlight.delete(inFlightKey);
}
}
}
async function ensureExtensionCopyAsyncInternal(
sourceDir: string,
extensionsRoot: string,
targetDir: string,
): Promise<ExtensionCopyResult> {
let shouldCopy = !(await pathExists(targetDir));
if (!shouldCopy) {
const [sourceHash, targetHash] = await Promise.all([
hashDirectoryContentsAsync(sourceDir),
hashDirectoryContentsAsync(targetDir),
]);
shouldCopy = sourceHash !== targetHash;
}
if (shouldCopy) {
await fs.promises.mkdir(extensionsRoot, { recursive: true });
await fs.promises.rm(targetDir, { recursive: true, force: true });
await fs.promises.cp(sourceDir, targetDir, { recursive: true });
}
return { targetDir, copied: shouldCopy };
}
@@ -4,7 +4,12 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { ensureExtensionCopy, shouldCopyYomitanExtension } from './yomitan-extension-copy';
import {
ensureExtensionCopy,
ensureExtensionCopyAsync,
shouldCopyYomitanExtension,
} from './yomitan-extension-copy';
import { withSuppressedYomitanExtensionWarnings } from './yomitan-extension-loader';
function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -15,6 +20,66 @@ function writeFile(filePath: string, content: string): void {
fs.writeFileSync(filePath, content, 'utf-8');
}
test('suppresses Yomitan contextMenus extension load warnings only while loading', async () => {
const emitted: string[] = [];
const warningProcess = {
emitWarning: (warning: string | Error, options?: { type?: string }) => {
const message = warning instanceof Error ? warning.message : warning;
emitted.push(`${options?.type ?? ''}:${message}`);
},
} as Pick<NodeJS.Process, 'emitWarning'>;
await withSuppressedYomitanExtensionWarnings(async () => {
warningProcess.emitWarning(
"Warnings loading extension:\nPermission 'contextMenus' is unknown.",
{
type: 'ExtensionLoadWarning',
},
);
warningProcess.emitWarning('Other extension warning', { type: 'ExtensionLoadWarning' });
return null;
}, warningProcess);
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
type: 'ExtensionLoadWarning',
});
assert.deepEqual(emitted, [
'ExtensionLoadWarning:Other extension warning',
"ExtensionLoadWarning:Permission 'contextMenus' is unknown.",
]);
});
test('suppressed Yomitan warning wrapper is re-entrant safe', async () => {
const emitted: string[] = [];
const warningProcess = {
emitWarning: (warning: string | Error, options?: { type?: string }) => {
const message = warning instanceof Error ? warning.message : warning;
emitted.push(`${options?.type ?? ''}:${message}`);
},
} as Pick<NodeJS.Process, 'emitWarning'>;
const originalEmitWarning = warningProcess.emitWarning;
await withSuppressedYomitanExtensionWarnings(async () => {
await withSuppressedYomitanExtensionWarnings(async () => {
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
type: 'ExtensionLoadWarning',
});
warningProcess.emitWarning('Nested warning', { type: 'ExtensionLoadWarning' });
}, warningProcess);
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
type: 'ExtensionLoadWarning',
});
warningProcess.emitWarning('Outer warning', { type: 'ExtensionLoadWarning' });
}, warningProcess);
assert.equal(warningProcess.emitWarning, originalEmitWarning);
assert.deepEqual(emitted, [
'ExtensionLoadWarning:Nested warning',
'ExtensionLoadWarning:Outer warning',
]);
});
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
const tempRoot = makeTempDir('subminer-yomitan-copy-');
const sourceDir = path.join(tempRoot, 'source');
@@ -82,3 +147,112 @@ test('ensureExtensionCopy refreshes copied extension when display files change',
'new display code',
);
});
test('ensureExtensionCopyAsync refreshes copied extension without completing synchronously', async () => {
const sourceRoot = makeTempDir('subminer-yomitan-src-');
const userDataRoot = makeTempDir('subminer-yomitan-user-');
const sourceDir = path.join(sourceRoot, 'yomitan');
const targetDir = path.join(userDataRoot, 'extensions', 'yomitan');
fs.mkdirSync(path.join(sourceDir, 'js', 'display'), { recursive: true });
fs.mkdirSync(path.join(targetDir, 'js', 'display'), { recursive: true });
fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(
path.join(sourceDir, 'js', 'display', 'structured-content-generator.js'),
'new display code',
);
fs.writeFileSync(
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
'old display code',
);
let completed = false;
const resultPromise = ensureExtensionCopyAsync(sourceDir, userDataRoot).then((result) => {
completed = true;
return result;
});
assert.equal(completed, false);
const result = await resultPromise;
assert.equal(result.targetDir, targetDir);
assert.equal(result.copied, true);
assert.equal(
fs.readFileSync(
path.join(targetDir, 'js', 'display', 'structured-content-generator.js'),
'utf8',
),
'new display code',
);
});
test('ensureExtensionCopyAsync shares an in-flight refresh for the same copied extension', async () => {
const sourceRoot = makeTempDir('subminer-yomitan-src-');
const userDataRoot = makeTempDir('subminer-yomitan-user-');
const sourceDir = path.join(sourceRoot, 'yomitan');
const targetDir = path.join(userDataRoot, 'extensions', 'yomitan');
fs.mkdirSync(path.join(sourceDir, 'js', 'pages', 'settings'), { recursive: true });
fs.mkdirSync(path.join(targetDir, 'js', 'pages', 'settings'), { recursive: true });
fs.writeFileSync(path.join(sourceDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify({ version: '1.0.0' }));
fs.writeFileSync(
path.join(sourceDir, 'js', 'pages', 'settings', 'settings-main.js'),
'new settings code',
);
fs.writeFileSync(
path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'),
'old settings code',
);
const originalCp = fs.promises.cp;
let cpCalls = 0;
let firstCopyStarted = false;
let releaseFirstCopy: () => void = () => {};
const firstCopyStartedPromise = new Promise<void>((resolve) => {
const fsPromises = fs.promises as typeof fs.promises & {
cp: typeof fs.promises.cp;
};
fsPromises.cp = async (...args: Parameters<typeof fs.promises.cp>) => {
cpCalls++;
if (!firstCopyStarted) {
firstCopyStarted = true;
resolve();
await new Promise<void>((release) => {
releaseFirstCopy = release;
});
}
return await originalCp(...args);
};
});
try {
const first = ensureExtensionCopyAsync(sourceDir, userDataRoot);
await firstCopyStartedPromise;
const second = ensureExtensionCopyAsync(sourceDir, userDataRoot);
releaseFirstCopy();
const results = await Promise.all([first, second]);
assert.equal(cpCalls, 1);
assert.equal(results[0].targetDir, targetDir);
assert.equal(results[1].targetDir, targetDir);
assert.equal(results[0].copied, true);
assert.equal(results[1].copied, true);
assert.equal(
fs.readFileSync(path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'), 'utf8'),
'new settings code',
);
} finally {
const fsPromises = fs.promises as typeof fs.promises & {
cp: typeof fs.promises.cp;
};
fsPromises.cp = originalCp;
}
});
+102 -10
View File
@@ -3,7 +3,7 @@ import type { BrowserWindow, Extension, Session } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from '../../logger';
import { ensureExtensionCopy } from './yomitan-extension-copy';
import { ensureExtensionCopyAsync } from './yomitan-extension-copy';
import {
getYomitanExtensionSearchPaths,
resolveExternalYomitanExtensionPath,
@@ -29,6 +29,85 @@ export interface YomitanExtensionLoaderDeps {
setYomitanSession: (session: Session | null) => void;
}
type WarningProcess = Pick<NodeJS.Process, 'emitWarning'>;
const suppressedWarningState = new WeakMap<
WarningProcess,
{
count: number;
originalEmitWarning: WarningProcess['emitWarning'];
}
>();
function getWarningType(warning: string | Error, args: unknown[]): string | undefined {
if (typeof warning !== 'string') {
return warning.name;
}
const firstArg = args[0];
if (typeof firstArg === 'string') {
return firstArg;
}
if (firstArg && typeof firstArg === 'object' && 'type' in firstArg) {
const type = (firstArg as { type?: unknown }).type;
return typeof type === 'string' ? type : undefined;
}
return undefined;
}
function shouldSuppressYomitanExtensionWarning(warning: string | Error, args: unknown[]): boolean {
const message = warning instanceof Error ? warning.message : warning;
return (
getWarningType(warning, args) === 'ExtensionLoadWarning' &&
message.includes("Permission 'contextMenus' is unknown.")
);
}
export async function withSuppressedYomitanExtensionWarnings<T>(
run: () => Promise<T>,
warningProcess: WarningProcess = process,
): Promise<T> {
const existingState = suppressedWarningState.get(warningProcess);
if (existingState) {
existingState.count++;
try {
return await run();
} finally {
existingState.count--;
if (existingState.count === 0) {
warningProcess.emitWarning = existingState.originalEmitWarning;
suppressedWarningState.delete(warningProcess);
}
}
}
const originalEmitWarning = warningProcess.emitWarning;
const state = {
count: 1,
originalEmitWarning,
};
suppressedWarningState.set(warningProcess, state);
warningProcess.emitWarning = ((warning: string | Error, ...args: unknown[]) => {
if (shouldSuppressYomitanExtensionWarning(warning, args)) {
return;
}
return (originalEmitWarning as (...emitArgs: unknown[]) => void).call(
warningProcess,
warning,
...args,
);
}) as typeof process.emitWarning;
try {
return await run();
} finally {
state.count--;
if (state.count === 0) {
warningProcess.emitWarning = originalEmitWarning;
suppressedWarningState.delete(warningProcess);
}
}
}
export async function loadYomitanExtension(
deps: YomitanExtensionLoaderDeps,
): Promise<Extension | null> {
@@ -79,9 +158,20 @@ export async function loadYomitanExtension(
return null;
}
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
let extensionCopy: { copied: boolean; targetDir: string };
try {
extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
} catch (error) {
logger.error('Failed to copy Yomitan extension:', {
error,
extensionPath: extPath,
userDataPath: deps.userDataPath,
});
clearRuntimeState();
return null;
}
if (extensionCopy.copied) {
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
logger.debug(`Copied yomitan extension to ${extensionCopy.targetDir}`);
}
extPath = extensionCopy.targetDir;
}
@@ -91,13 +181,15 @@ export async function loadYomitanExtension(
try {
const extensions = targetSession.extensions;
const extension = extensions
? await extensions.loadExtension(extPath, {
allowFileAccess: true,
})
: await targetSession.loadExtension(extPath, {
allowFileAccess: true,
});
const extension = await withSuppressedYomitanExtensionWarnings(() =>
extensions
? extensions.loadExtension(extPath, {
allowFileAccess: true,
})
: targetSession.loadExtension(extPath, {
allowFileAccess: true,
}),
);
deps.setYomitanExtension(extension);
return extension;
} catch (err) {
+145
View File
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildYomitanSettingsCloseButtonScript,
buildYomitanSettingsWindowMenuTemplate,
buildYomitanSettingsUrl,
configureYomitanSettingsWindowChrome,
destroyYomitanSettingsWindow,
installYomitanSettingsCloseButton,
showYomitanSettingsWindow,
shouldInstallYomitanSettingsCloseButton,
} from './yomitan-settings';
test('yomitan settings window uses a close-only menu without app quit', () => {
const calls: string[] = [];
configureYomitanSettingsWindowChrome({
isDestroyed: () => false,
close: () => calls.push('close'),
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
} as never, (template) => {
calls.push(`menu-label:${template[0]?.label ?? ''}`);
const submenu = template[0]?.submenu;
assert.ok(Array.isArray(submenu));
const closeItem = submenu[0];
assert.equal(closeItem?.label, 'Close');
assert.notEqual(closeItem?.role, 'quit');
closeItem?.click?.({} as never, {} as never, {} as never);
return { id: 'settings-menu' } as never;
});
assert.deepEqual(calls, ['auto-hide:false', 'menu-label:File', 'close', 'menu:custom']);
});
test('yomitan settings close menu skips destroyed windows', () => {
const calls: string[] = [];
const template = buildYomitanSettingsWindowMenuTemplate({
isDestroyed: () => true,
close: () => calls.push('close'),
} as never);
const submenu = template[0]?.submenu;
assert.ok(Array.isArray(submenu));
submenu[0]?.click?.({} as never, {} as never, {} as never);
assert.deepEqual(calls, []);
});
test('yomitan settings close button script installs an idempotent in-page close control', () => {
const script = buildYomitanSettingsCloseButtonScript();
assert.match(script, /subminer-yomitan-settings-close/);
assert.match(script, /aria-label', 'Close Yomitan settings'/);
assert.match(script, /window\.close\(\)/);
assert.match(script, /getElementById\(buttonId\)/);
});
test('yomitan settings close button only installs for Hyprland sessions', () => {
assert.equal(
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
true,
);
assert.equal(
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: '' }),
false,
);
assert.equal(
shouldInstallYomitanSettingsCloseButton('darwin', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
false,
);
assert.equal(
shouldInstallYomitanSettingsCloseButton('win32', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
false,
);
});
test('yomitan settings close button injection skips non-Hyprland windows', () => {
const calls: string[] = [];
installYomitanSettingsCloseButton(
{
isDestroyed: () => false,
webContents: {
executeJavaScript: () => {
calls.push('execute');
return Promise.resolve();
},
},
} as never,
{ platform: 'darwin', env: { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' } },
);
assert.deepEqual(calls, []);
});
test('yomitan settings URL disables the embedded popup preview', () => {
assert.equal(
buildYomitanSettingsUrl('abc123'),
'chrome-extension://abc123/settings.html?popup-preview=false',
);
});
test('showYomitanSettingsWindow restores, repaints, shows, and focuses an existing window', () => {
const calls: string[] = [];
showYomitanSettingsWindow({
isDestroyed: () => false,
isMinimized: () => true,
restore: () => calls.push('restore'),
getSize: () => [1200, 800],
setSize: (width: number, height: number) => calls.push(`set-size:${width}x${height}`),
webContents: {
invalidate: () => calls.push('invalidate'),
},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
} as never);
assert.deepEqual(calls, ['restore', 'set-size:1200x800', 'invalidate', 'show', 'focus']);
});
test('destroyYomitanSettingsWindow destroys a live settings window before app quit', () => {
const calls: string[] = [];
const destroyed = destroyYomitanSettingsWindow({
isDestroyed: () => false,
destroy: () => calls.push('destroy'),
} as never);
assert.equal(destroyed, true);
assert.deepEqual(calls, ['destroy']);
});
test('destroyYomitanSettingsWindow skips missing or already destroyed settings windows', () => {
assert.equal(destroyYomitanSettingsWindow(null), false);
assert.equal(
destroyYomitanSettingsWindow({
isDestroyed: () => true,
destroy: () => {
throw new Error('should not destroy twice');
},
} as never),
false,
);
});
+154 -11
View File
@@ -1,8 +1,8 @@
import electron from 'electron';
import type { BrowserWindow, Extension, Session } from 'electron';
import type { BrowserWindow, Extension, Menu, MenuItemConstructorOptions, Session } from 'electron';
import { createLogger } from '../../logger';
const { BrowserWindow: ElectronBrowserWindow, session } = electron;
const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = electron;
const logger = createLogger('main:yomitan-settings');
export interface OpenYomitanSettingsWindowOptions {
@@ -13,6 +13,151 @@ export interface OpenYomitanSettingsWindowOptions {
onWindowClosed?: () => void;
}
type YomitanSettingsWindowMenuOwner = Pick<BrowserWindow, 'close' | 'isDestroyed'>;
type HyprlandSessionEnv = {
HYPRLAND_INSTANCE_SIGNATURE?: string;
};
export interface InstallYomitanSettingsCloseButtonOptions {
platform?: NodeJS.Platform;
env?: HyprlandSessionEnv;
}
export function shouldInstallYomitanSettingsCloseButton(
platform: NodeJS.Platform = process.platform,
env: HyprlandSessionEnv = process.env,
): boolean {
return platform === 'linux' && Boolean(env.HYPRLAND_INSTANCE_SIGNATURE);
}
export function buildYomitanSettingsWindowMenuTemplate(
settingsWindow: YomitanSettingsWindowMenuOwner,
): MenuItemConstructorOptions[] {
return [
{
label: 'File',
submenu: [
{
label: 'Close',
accelerator: process.platform === 'darwin' ? 'Command+W' : 'Ctrl+W',
click: () => {
if (!settingsWindow.isDestroyed()) {
settingsWindow.close();
}
},
},
],
},
];
}
export function buildYomitanSettingsCloseButtonScript(): string {
return `
(() => {
const buttonId = 'subminer-yomitan-settings-close';
const styleId = 'subminer-yomitan-settings-close-style';
if (document.getElementById(buttonId)) {
return;
}
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = \`
#\${buttonId} {
position: fixed;
top: 10px;
left: 10px;
z-index: 2147483647;
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 4px;
background: rgba(24, 24, 24, 0.92);
color: #f2f2f2;
font: 22px/1 system-ui, sans-serif;
cursor: pointer;
}
#\${buttonId}:hover {
background: rgba(54, 54, 54, 0.96);
border-color: rgba(255, 255, 255, 0.5);
}
#\${buttonId}:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: 2px;
}
\`;
document.head.appendChild(style);
}
const button = document.createElement('button');
button.id = buttonId;
button.type = 'button';
button.title = 'Close';
button.setAttribute('aria-label', 'Close Yomitan settings');
button.textContent = '\\u00d7';
button.addEventListener('click', () => {
window.close();
});
document.body.appendChild(button);
})();
`;
}
export function installYomitanSettingsCloseButton(
settingsWindow: Pick<BrowserWindow, 'isDestroyed' | 'webContents'>,
options: InstallYomitanSettingsCloseButtonOptions = {},
): void {
if (settingsWindow.isDestroyed()) {
return;
}
if (!shouldInstallYomitanSettingsCloseButton(options.platform, options.env)) {
return;
}
settingsWindow.webContents
.executeJavaScript(buildYomitanSettingsCloseButtonScript())
.catch((error: Error) => {
logger.warn('Failed to install Yomitan settings close button:', error.message);
});
}
export function configureYomitanSettingsWindowChrome(
settingsWindow: Pick<BrowserWindow, 'close' | 'isDestroyed' | 'setAutoHideMenuBar' | 'setMenu'>,
buildMenu: (template: MenuItemConstructorOptions[]) => Menu = (template) =>
ElectronMenu.buildFromTemplate(template),
): void {
settingsWindow.setAutoHideMenuBar(false);
settingsWindow.setMenu(buildMenu(buildYomitanSettingsWindowMenuTemplate(settingsWindow)));
}
export function buildYomitanSettingsUrl(extensionId: string): string {
return `chrome-extension://${extensionId}/settings.html?popup-preview=false`;
}
export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
if (settingsWindow.isDestroyed()) {
return;
}
if (settingsWindow.isMinimized()) {
settingsWindow.restore();
}
const [width = 0, height = 0] = settingsWindow.getSize();
settingsWindow.setSize(width, height);
settingsWindow.webContents.invalidate();
settingsWindow.show();
settingsWindow.focus();
}
export function destroyYomitanSettingsWindow(settingsWindow: BrowserWindow | null): boolean {
if (!settingsWindow || settingsWindow.isDestroyed()) {
return false;
}
settingsWindow.destroy();
return true;
}
export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOptions): void {
logger.info('openYomitanSettings called');
@@ -24,8 +169,8 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
const existingWindow = options.getExistingWindow();
if (existingWindow && !existingWindow.isDestroyed()) {
logger.info('Settings window already exists, focusing');
existingWindow.focus();
logger.info('Settings window already exists, showing and focusing');
showYomitanSettingsWindow(existingWindow);
return;
}
@@ -35,15 +180,17 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
width: 1200,
height: 800,
show: false,
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
session: options.yomitanSession ?? session.defaultSession,
},
});
configureYomitanSettingsWindowChrome(settingsWindow);
options.setWindow(settingsWindow);
const settingsUrl = `chrome-extension://${options.yomitanExt.id}/settings.html`;
const settingsUrl = buildYomitanSettingsUrl(options.yomitanExt.id);
logger.info('Loading settings URL:', settingsUrl);
let loadAttempts = 0;
@@ -73,15 +220,11 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
settingsWindow.webContents.on('did-finish-load', () => {
logger.info('Settings page loaded successfully');
installYomitanSettingsCloseButton(settingsWindow);
});
setTimeout(() => {
if (!settingsWindow.isDestroyed()) {
const [width = 0, height = 0] = settingsWindow.getSize();
settingsWindow.setSize(width, height);
settingsWindow.webContents.invalidate();
settingsWindow.show();
}
showYomitanSettingsWindow(settingsWindow);
}, 500);
settingsWindow.on('closed', () => {

Some files were not shown because too many files have changed in this diff Show More