Compare commits

...

4 Commits

110 changed files with 5196 additions and 218 deletions
+11 -2
View File
@@ -139,7 +139,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 +219,8 @@ jobs:
path: |
release/*.dmg
release/*.zip
release/*.yml
release/*.blockmap
if-no-files-found: error
build-windows:
@@ -267,6 +272,8 @@ jobs:
path: |
release/*.exe
release/*.zip
release/*.yml
release/*.blockmap
if-no-files-found: error
release:
@@ -339,7 +346,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
@@ -371,6 +378,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
)
+4 -4
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>
@@ -222,7 +222,7 @@ sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/sub
```
> [!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.
</details>
@@ -231,7 +231,7 @@ sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/sub
Download the latest installer (`.exe`) [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Make sure `mpv` is on your `PATH`.
**Note:** On Windows the recommended way to run SubMiner is with 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>
@@ -248,7 +248,7 @@ 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 Yomitan dictionaries. Follow the on-screen steps to complete setup.
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.
+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/support asset updates, checksum verification, configurable update notifications, and an opt-in prerelease update channel for beta/RC testing.
@@ -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.
+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 Linux window managers that do not show native decorations.
- 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.
+12
View File
@@ -155,6 +155,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.subminer.moe';
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);
-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=="],
+81 -58
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
@@ -859,59 +882,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.
+31 -3
View File
@@ -164,6 +164,18 @@ cp -R /tmp/plugin/subminer/. ~/.local/share/SubMiner/plugin/subminer/
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/support asset 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.
### From Source
```bash
@@ -204,6 +216,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 +232,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/support asset 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`.
:::
@@ -320,7 +344,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"
@@ -330,7 +355,9 @@ The shortcut and `--launch-mpv` pass SubMiner's default IPC socket, subtitle arg
### Windows-Specific Notes
- The **SubMiner mpv** shortcut created during first-run setup is the recommended way to launch playback on Windows. The `subminer` launcher script is primarily for Linux and macOS.
- 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`.
@@ -394,8 +421,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`.
+2
View File
@@ -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 |
-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.subminer.moe'",
);
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');
});
+12
View File
@@ -155,6 +155,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"**
+8
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,6 +53,8 @@
`bun run test:fast`
`bun run test:env`
`bun run build`
When validating packaged updater output, confirm the platform build writes
`*.yml` and `*.blockmap` files under `release/`.
5. Commit the prerelease prep. Do not run `bun run changelog:build`.
6. Tag the commit: `git tag v<version>`.
7. Push commit + tag.
@@ -72,3 +77,6 @@ Notes:
- 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 forwards launcher path and waits for response', async () => {
const context = createContext();
context.args.update = true;
const forwarded: string[][] = [];
const responses: string[] = [];
const handled = await runUpdateCommand(context, {
createTempDir: () => '/tmp/subminer-update-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandCaptureOutput: (_appPath, appArgs) => {
forwarded.push(appArgs);
return { status: 0, stdout: '', stderr: '' };
},
waitForUpdateResponse: async (responsePath) => {
responses.push(responsePath);
return { ok: true, status: 'up-to-date', version: '0.15.0' };
},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [
[
'--update',
'--update-launcher-path',
'/tmp/subminer',
'--update-response-path',
'/tmp/subminer-update-test/response.json',
],
]);
assert.deepEqual(responses, ['/tmp/subminer-update-test/response.json']);
});
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);
+87
View File
@@ -0,0 +1,87 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runAppCommandCaptureOutput } from '../mpv.js';
import { nowMs } from '../time.js';
import { sleep } from '../util.js';
import type { LauncherCommandContext } from './context.js';
type UpdateCommandResponse = {
ok: boolean;
status?: string;
version?: string;
error?: 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;
};
const UPDATE_RESPONSE_TIMEOUT_MS = 10 * 60 * 1000;
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 });
},
};
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;
}
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;
+14 -1
View File
@@ -48,7 +48,7 @@
"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: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/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: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",
@@ -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"
+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
+61
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', () => {
@@ -2124,6 +2176,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 +2263,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,
+7
View File
@@ -14,6 +14,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'youtube'
| 'subsync'
| 'startupWarmups'
| 'updates'
| 'auto_start_overlay'
> = {
subtitlePosition: { yPercent: 10 },
@@ -116,5 +117,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',
+26
View File
@@ -383,6 +383,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.'],
+56
View File
@@ -478,6 +478,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',
+33
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,
@@ -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');
},
@@ -363,6 +368,34 @@ test('handleCliCommand opens first-run setup window for --setup', () => {
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 () => {
+16
View File
@@ -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']>;
@@ -174,6 +175,7 @@ interface AnilistCliRuntime {
interface AppCliRuntime {
stop: () => void;
hasMainWindow: () => boolean;
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
}
@@ -277,6 +279,7 @@ 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,
@@ -416,6 +419,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,
@@ -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);
});
+9 -6
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();
}
@@ -238,7 +241,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
}
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
@@ -248,7 +251,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.logDebug?.('App-ready critical path started.');
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
@@ -319,12 +322,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();
+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,11 @@ 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';
function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -82,3 +86,115 @@ 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;
}
});
@@ -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,
@@ -79,7 +79,7 @@ export async function loadYomitanExtension(
return null;
}
const extensionCopy = ensureExtensionCopy(extPath, deps.userDataPath);
const extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
if (extensionCopy.copied) {
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
}
+105
View File
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildYomitanSettingsCloseButtonScript,
buildYomitanSettingsWindowMenuTemplate,
buildYomitanSettingsUrl,
configureYomitanSettingsWindowChrome,
destroyYomitanSettingsWindow,
showYomitanSettingsWindow,
} 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 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,
);
});
+134 -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,131 @@ export interface OpenYomitanSettingsWindowOptions {
onWindowClosed?: () => void;
}
type YomitanSettingsWindowMenuOwner = Pick<BrowserWindow, 'close' | 'isDestroyed'>;
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'>,
): void {
if (settingsWindow.isDestroyed()) {
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 +149,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 +160,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 +200,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', () => {
+201 -2
View File
@@ -124,6 +124,7 @@ import type {
SecondarySubMode,
SubtitleData,
SubtitlePosition,
UpdateChannel,
WindowGeometry,
} from './types';
import { AnkiIntegration } from './anki-integration';
@@ -313,6 +314,7 @@ import {
createTokenizerDepsRuntime,
cycleSecondarySubMode as cycleSecondarySubModeCore,
deleteYomitanDictionaryByTitle,
destroyYomitanSettingsWindow,
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
getYomitanDictionaryInfo,
@@ -393,6 +395,11 @@ import {
detectWindowsMpvShortcuts,
resolveWindowsMpvShortcutPaths,
} from './main/runtime/windows-mpv-shortcuts';
import {
detectCommandLineLauncher,
installBun as installCommandLineBun,
installLauncher as installCommandLineLauncher,
} from './main/runtime/command-line-launcher';
import {
createWindowsMpvLaunchDeps,
getConfiguredWindowsMpvPathStatus,
@@ -405,7 +412,10 @@ import {
toggleJellyfinDiscoveryFromTray as toggleJellyfinDiscoveryFromTrayRuntime,
} from './main/runtime/jellyfin-tray-discovery';
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
import {
shouldEnsureTrayOnStartupForInitialArgs,
shouldQuitOnWindowAllClosedForTrayState,
} from './main/runtime/startup-tray-policy';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
import {
@@ -498,6 +508,31 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { createElectronAppUpdater } from './main/runtime/update/app-updater';
import {
fetchLatestStableRelease,
fetchReleaseAssetBuffer,
fetchReleaseAssetText,
findReleaseAsset,
parseSha256Sums,
} from './main/runtime/update/release-assets';
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
import {
showNoUpdateDialog,
showRestartDialog,
showUpdateAvailableDialog,
showUpdateFailedDialog,
} from './main/runtime/update/update-dialogs';
import {
runUpdateCliCommand,
writeUpdateCliCommandResponse,
} from './main/runtime/update/update-cli-command';
import {
createFileUpdateStateStore,
createUpdateService,
} from './main/runtime/update/update-service';
import { updateSupportAssetsFromRelease } from './main/runtime/update/support-assets';
import {
createRefreshSubtitlePrefetchFromActiveTrackHandler,
createResolveActiveSubtitleSidebarSourceHandler,
@@ -875,6 +910,8 @@ function stopStatsServer(): void {
}
function requestAppQuit(): void {
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
appState.yomitanSettingsWindow = null;
destroyStatsWindow();
stopStatsServer();
if (!forceQuitTimer) {
@@ -1199,6 +1236,16 @@ const resolveWindowsMpvShortcutRuntimePaths = () =>
appDataDir: app.getPath('appData'),
desktopDir: app.getPath('desktop'),
});
const createCommandLineLauncherRuntimeOptions = () => ({
platform: process.platform,
env: process.env,
homeDir: os.homedir(),
localAppData: process.env.LOCALAPPDATA,
userProfile: process.env.USERPROFILE,
cwd: process.cwd(),
resourcesPath: process.resourcesPath,
appExePath: process.execPath,
});
syncInstalledFirstRunPluginBinaryPath({
platform: process.platform,
homeDir: os.homedir(),
@@ -1274,6 +1321,32 @@ const firstRunSetupService = createFirstRunSetupService({
shell.writeShortcutLink(shortcutPath, operation, details),
});
},
detectCommandLineLauncher: () =>
detectCommandLineLauncher(createCommandLineLauncherRuntimeOptions()),
installBun: async () => {
const snapshot = await installCommandLineBun(createCommandLineLauncherRuntimeOptions());
return {
ok: snapshot.status === 'ready',
message:
snapshot.message ??
(snapshot.status === 'ready'
? 'Bun is ready. Open a new terminal.'
: 'Bun installation failed.'),
};
},
installCommandLineLauncher: async () => {
const snapshot = await installCommandLineLauncher(createCommandLineLauncherRuntimeOptions());
const ok = snapshot.status === 'ready' || snapshot.status === 'installed_bun_missing';
return {
ok,
installPath: snapshot.installPath,
message:
snapshot.message ??
(ok
? 'Command-line launcher installed. Open a new terminal.'
: 'Command-line launcher installation failed.'),
};
},
onStateChanged: (state) => {
appState.firstRunSetupCompleted = state.status === 'completed';
if (appTray) {
@@ -2749,6 +2822,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
mpvExecutablePath,
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
commandLineLauncher: snapshot.commandLineLauncher,
message: firstRunSetupMessage,
};
},
@@ -2784,6 +2858,16 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'install-bun') {
const snapshot = await firstRunSetupService.installBun();
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'install-command-line-launcher') {
const snapshot = await firstRunSetupService.installCommandLineLauncher();
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'open-yomitan-settings') {
firstRunSetupMessage = openYomitanSettings()
? 'Opened Yomitan settings. Install dictionaries, then refresh status.'
@@ -3650,6 +3734,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
logWarning: (message) => appLogger.logWarning(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
startConfigHotReload: () => configHotReloadRuntime.start(),
shouldRefreshAnilistClientSecretState: () =>
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretStateIfEnabled(options),
failHandlers: {
logError: (details) => logger.error(details),
@@ -3768,6 +3854,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
loadYomitanExtension: async () => {
await loadYomitanExtension();
},
ensureYomitanExtensionLoaded: async () => {
await ensureYomitanExtensionLoaded();
},
handleFirstRunSetup: async () => {
const snapshot = await firstRunSetupService.ensureSetupStateInitialized();
appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
@@ -4518,6 +4607,92 @@ flushPendingMpvLogWrites = () => {
void flushMpvLog();
};
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
let updateService: ReturnType<typeof createUpdateService> | null = null;
function getFetchForUpdater() {
return globalThis.fetch.bind(globalThis);
}
async function updateLauncherFromLatestRelease(
launcherPath?: string,
channel: UpdateChannel = getResolvedConfig().updates.channel,
) {
const fetchForUpdater = getFetchForUpdater();
const release = await fetchLatestStableRelease({ fetch: fetchForUpdater, channel });
if (!release) {
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
}
const sumsAsset = findReleaseAsset(release, 'SHA256SUMS.txt');
if (!sumsAsset) {
return { status: 'missing-asset', message: 'Release has no SHA256SUMS.txt asset.' };
}
const sums = parseSha256Sums(
await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url),
);
const launcherResult = await updateLauncherFromRelease({
release,
sha256Sums: sums,
launcherPath,
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
});
const supportResults = await updateSupportAssetsFromRelease({
release,
sha256Sums: sums,
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
});
for (const result of supportResults) {
if (result.status === 'protected' && result.command) {
logger.warn(`Support assets update requires manual command: ${result.command}`);
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
logger.warn(`Support assets update skipped: ${result.message ?? result.status}`);
}
}
return launcherResult;
}
function getUpdateService() {
if (updateService) return updateService;
const appUpdater = createElectronAppUpdater({
currentVersion: app.getVersion(),
isPackaged: app.isPackaged,
log: (message) => logger.info(message),
getChannel: () => getResolvedConfig().updates.channel,
});
updateService = createUpdateService({
getConfig: () => getResolvedConfig().updates,
getCurrentVersion: () => app.getVersion(),
now: () => Date.now(),
readState: () => updateStateStore.readState(),
writeState: (state) => updateStateStore.writeState(state),
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
fetchLatestStableRelease: (channel) =>
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
updateLauncher: (launcherPath, channel) =>
updateLauncherFromLatestRelease(launcherPath, channel),
showNoUpdateDialog: (version) =>
showNoUpdateDialog((options) => dialog.showMessageBox(options), version),
showUpdateAvailableDialog: (version) =>
showUpdateAvailableDialog((options) => dialog.showMessageBox(options), version),
showUpdateFailedDialog: (message) =>
showUpdateFailedDialog((options) => dialog.showMessageBox(options), message),
downloadAppUpdate: () => appUpdater.downloadUpdate(),
showRestartDialog: () => showRestartDialog((options) => dialog.showMessageBox(options)),
quitAndInstall: () => appUpdater.quitAndInstall(),
notifyUpdateAvailable: (version) =>
notifyUpdateAvailable(
{ notificationType: getResolvedConfig().updates.notificationType, version },
{
showSystemNotification: (title, body) => showDesktopNotification(title, { body }),
showOsdNotification: (message) => showMpvOsd(message),
log: (message) => logger.warn(message),
},
),
log: (message) => logger.warn(message),
});
return updateService;
}
const cycleSecondarySubMode = createCycleSecondarySubModeRuntimeHandler({
cycleSecondarySubModeMainDeps: {
getSecondarySubMode: () => appState.secondarySubMode,
@@ -5173,6 +5348,14 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
runStatsCliCommand(argsFromCommand, source),
runUpdateCommand: async (argsFromCommand: CliArgs, source: CliCommandSource) => {
await runUpdateCliCommand(argsFromCommand, source, {
checkForUpdates: (request) => getUpdateService().checkForUpdates(request),
writeResponse: (responsePath, payload) =>
writeUpdateCliCommandResponse(responsePath, payload),
logWarn: (message, error) => logger.warn(message, error),
});
},
runYoutubePlaybackFlow: (request) => youtubePlaybackRuntime.runYoutubePlaybackFlow(request),
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
@@ -5240,7 +5423,11 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
shouldQuitOnWindowAllClosed: () => !appState.backgroundMode,
shouldQuitOnWindowAllClosed: () =>
shouldQuitOnWindowAllClosedForTrayState({
backgroundMode: appState.backgroundMode,
hasTray: Boolean(appTray),
}),
},
createAppLifecycleRuntimeRunner: (params) => createAppLifecycleRuntimeRunner(params),
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
@@ -5283,6 +5470,12 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
});
runAndApplyStartupState();
void app.whenReady().then(() => {
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
return;
}
getUpdateService().startAutomaticChecks();
});
const startupModeFlags = getStartupModeFlags(appState.initialArgs);
const shouldUseMinimalStartup = startupModeFlags.shouldUseMinimalStartup;
const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup;
@@ -5367,6 +5560,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
openSessionHelpModal: () => openSessionHelpOverlay(),
openTexthookerInBrowser: () =>
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
showTexthookerPage: () => getResolvedConfig().texthooker.launchAtStartup !== false,
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
@@ -5379,6 +5573,9 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
toggleJellyfinDiscovery: () =>
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()),
openAnilistSetupWindow: () => openAnilistSetupWindow(),
checkForUpdates: () => {
void getUpdateService().checkForUpdates({ source: 'manual' });
},
quitApp: () => requestAppQuit(),
},
ensureTrayDeps: {
@@ -5534,6 +5731,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
});
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
getYomitanExtension: () => appState.yomitanExt,
getYomitanExtensionLoadInFlight: () => yomitanLoadInFlight,
getYomitanSession: () => appState.yomitanSession,
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {
openYomitanSettingsWindow({
+2
View File
@@ -44,6 +44,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
ensureYomitanExtensionLoaded?: AppReadyRuntimeDeps['ensureYomitanExtensionLoaded'];
handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup'];
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
@@ -109,6 +110,7 @@ export function createAppReadyRuntimeDeps(
createImmersionTracker: params.createImmersionTracker,
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
loadYomitanExtension: params.loadYomitanExtension,
ensureYomitanExtensionLoaded: params.ensureYomitanExtensionLoaded,
handleFirstRunSetup: params.handleFirstRunSetup,
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
startBackgroundWarmups: params.startBackgroundWarmups,
+2
View File
@@ -43,6 +43,7 @@ export interface CliCommandRuntimeServiceContext {
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
@@ -118,6 +119,7 @@ function createCliCommandDepsFromContext(
app: {
stop: context.stopApp,
hasMainWindow: context.hasMainWindow,
runUpdateCommand: context.runUpdateCommand,
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
},
dispatchSessionAction: context.dispatchSessionAction,
+2
View File
@@ -184,6 +184,7 @@ export interface CliCommandRuntimeServiceDepsParams {
app: {
stop: CliCommandDepsRuntimeOptions['app']['stop'];
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
};
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
@@ -362,6 +363,7 @@ export function createCliCommandRuntimeServiceDeps(
app: {
stop: params.app.stop,
hasMainWindow: params.app.hasMainWindow,
runUpdateCommand: params.app.runUpdateCommand,
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
},
dispatchSessionAction: params.dispatchSessionAction,
@@ -36,6 +36,9 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
loadYomitanExtension: async () => {
calls.push('load-yomitan');
},
ensureYomitanExtensionLoaded: async () => {
calls.push('ensure-yomitan');
},
handleFirstRunSetup: async () => {
calls.push('handle-first-run-setup');
},
@@ -67,6 +70,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
onReady.createMpvClient();
await onReady.createMecabTokenizerAndCheck();
await onReady.loadYomitanExtension();
await onReady.ensureYomitanExtensionLoaded?.();
await onReady.handleFirstRunSetup();
await onReady.prewarmSubtitleDictionaries?.();
onReady.startBackgroundWarmups();
@@ -79,6 +83,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
'create-mpv-client',
'create-mecab',
'load-yomitan',
'ensure-yomitan',
'handle-first-run-setup',
'prewarm-dicts',
'start-warmups',
+1
View File
@@ -27,6 +27,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
createImmersionTracker: deps.createImmersionTracker,
startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
loadYomitanExtension: deps.loadYomitanExtension,
ensureYomitanExtensionLoaded: deps.ensureYomitanExtensionLoaded,
handleFirstRunSetup: deps.handleFirstRunSetup,
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
startBackgroundWarmups: deps.startBackgroundWarmups,
@@ -68,9 +68,12 @@ test('open yomitan settings main deps map async open callbacks', async () => {
const calls: string[] = [];
let currentWindow: unknown = null;
const extension = { id: 'ext' };
const startupLoad = Promise.resolve(extension);
const yomitanSession = { id: 'session' };
const deps = createBuildOpenYomitanSettingsMainDepsHandler({
ensureYomitanExtensionLoaded: async () => extension,
getYomitanExtension: () => extension,
getYomitanExtensionLoadInFlight: () => startupLoad,
openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) =>
calls.push(
`open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`,
@@ -86,6 +89,8 @@ test('open yomitan settings main deps map async open callbacks', async () => {
})();
assert.equal(await deps.ensureYomitanExtensionLoaded(), extension);
assert.equal(deps.getYomitanExtension?.(), extension);
assert.equal(deps.getYomitanExtensionLoadInFlight?.(), startupLoad);
assert.equal(deps.getExistingWindow(), null);
deps.setWindow({ id: 'win' });
deps.openYomitanSettingsWindow({
+11
View File
@@ -62,6 +62,8 @@ export function createBuildInitializeOverlayRuntimeBootstrapMainDepsHandler<TOpt
export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWindow>(deps: {
ensureYomitanExtensionLoaded: () => Promise<TYomitanExt | null>;
getYomitanExtension?: () => TYomitanExt | null;
getYomitanExtensionLoadInFlight?: () => Promise<unknown> | null;
openYomitanSettingsWindow: (params: {
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
@@ -77,6 +79,15 @@ export function createBuildOpenYomitanSettingsMainDepsHandler<TYomitanExt, TWind
}) {
return () => ({
ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(),
...(deps.getYomitanExtension
? { getYomitanExtension: () => deps.getYomitanExtension?.() ?? null }
: {}),
...(deps.getYomitanExtensionLoadInFlight
? {
getYomitanExtensionLoadInFlight: () =>
deps.getYomitanExtensionLoadInFlight?.() ?? null,
}
: {}),
openYomitanSettingsWindow: (params: {
yomitanExt: TYomitanExt;
getExistingWindow: () => TWindow | null;
@@ -63,6 +63,9 @@ test('build cli command context deps maps handlers and values', () => {
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
runUpdateCommand: async () => {
calls.push('run-update');
},
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
@@ -41,6 +41,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
@@ -94,6 +95,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand,
runUpdateCommand: deps.runUpdateCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -71,6 +71,7 @@ test('cli command context factory composes main deps and context handlers', () =
}),
runStatsCommand: async () => {},
runJellyfinCommand: async () => {},
runUpdateCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
@@ -92,6 +92,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
runJellyfinCommand: async () => {
calls.push('run-jellyfin');
},
runUpdateCommand: async () => {
calls.push('run-update');
},
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube-playback');
},
@@ -1,4 +1,4 @@
import type { CliArgs } from '../../cli/args';
import type { CliArgs, CliCommandSource } from '../../cli/args';
import { resolveTexthookerWebsocketUrl } from '../../core/services/startup';
import type { CliCommandContextFactoryDeps } from './cli-command-context';
@@ -53,6 +53,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
setCharacterDictionarySelection?: CliCommandContextFactoryDeps['setCharacterDictionarySelection'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
@@ -121,6 +122,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
setCharacterDictionarySelection: deps.setCharacterDictionarySelection,
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
runUpdateCommand: (args: CliArgs, source: CliCommandSource) =>
deps.runUpdateCommand(args, source),
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
openYomitanSettings: () => deps.openYomitanSettings(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
@@ -53,6 +53,7 @@ function createDeps() {
}),
runStatsCommand: async () => {},
runJellyfinCommand: async () => {},
runUpdateCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
+2
View File
@@ -46,6 +46,7 @@ export type CliCommandContextFactoryDeps = {
setCharacterDictionarySelection?: CliCommandRuntimeServiceContext['setCharacterDictionarySelection'];
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>;
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
@@ -121,6 +122,7 @@ export function createCliCommandContext(
})),
runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand,
runUpdateCommand: deps.runUpdateCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode,
@@ -0,0 +1,173 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
export interface RunCommandResult {
exitCode: number | null;
stdout: string;
stderr: string;
}
export type RunCommand = (
command: string,
args: string[],
options?: { timeoutMs?: number; env?: NodeJS.ProcessEnv },
) => Promise<RunCommandResult>;
export type FsDeps = {
existsSync?: (candidate: string) => boolean;
accessSync?: (candidate: string, mode?: number) => void;
mkdirSync?: (candidate: string, options?: { recursive?: boolean }) => unknown;
copyFileSync?: (from: string, to: string) => void;
writeFileSync?: (candidate: string, content: string, encoding?: BufferEncoding) => void;
readFileSync?: (candidate: string, encoding?: BufferEncoding) => string;
chmodSync?: (candidate: string, mode: number) => void;
};
export type CommonOptions = FsDeps & {
platform?: NodeJS.Platform;
env?: Record<string, string | undefined>;
homeDir?: string;
cwd?: string;
resourcesPath?: string;
appExePath?: string;
launcherResourcePath?: string;
runCommand?: RunCommand;
};
export type WindowsPathOptions = {
localAppData?: string;
userProfile?: string;
getUserPath?: () => string;
setUserPath?: (nextPath: string) => void | Promise<void>;
broadcastEnvironmentChange?: () => void | Promise<void>;
};
export function platformOf(options: CommonOptions): NodeJS.Platform {
return options.platform ?? process.platform;
}
export function envOf(options: CommonOptions): Record<string, string | undefined> {
return options.env ?? process.env;
}
export function pathModuleFor(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix;
}
export function existsSyncOf(options: FsDeps): (candidate: string) => boolean {
return options.existsSync ?? fs.existsSync;
}
export function accessSyncOf(options: FsDeps): (candidate: string, mode?: number) => void {
return options.accessSync ?? fs.accessSync;
}
export function splitPath(value: string | undefined, platform: NodeJS.Platform): string[] {
if (!value) return [];
const delimiter = platform === 'win32' ? ';' : ':';
return value
.split(delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
}
export function normalizePathForCompare(
candidate: string,
platform: NodeJS.Platform,
platformPath = pathModuleFor(platform),
): string {
const normalized = platformPath.normalize(candidate).replace(/[\\/]+$/, '');
return platform === 'win32' ? normalized.toLowerCase() : normalized;
}
export function pathEntriesContain(
entries: string[],
dir: string,
platform: NodeJS.Platform,
): boolean {
const normalizedDir = normalizePathForCompare(dir, platform);
return entries.some((entry) => normalizePathForCompare(entry, platform) === normalizedDir);
}
function isExecutableFile(candidate: string, options: CommonOptions): boolean {
try {
if (!existsSyncOf(options)(candidate)) return false;
if (options.existsSync && !options.accessSync) return true;
accessSyncOf(options)(candidate, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
export function findCommand(command: string, options: CommonOptions): string | null {
const platform = platformOf(options);
const platformPath = pathModuleFor(platform);
const entries = splitPath(envOf(options).PATH, platform);
const hasExtension = platformPath.extname(command) !== '';
const extensions =
platform === 'win32'
? hasExtension
? ['']
: (envOf(options).PATHEXT?.split(';').filter(Boolean) ?? [
'.exe',
'.cmd',
'.bat',
'.EXE',
'.CMD',
'.BAT',
])
: [''];
for (const entry of entries) {
for (const extension of extensions) {
const candidate = platformPath.join(entry, `${command}${extension}`);
if (isExecutableFile(candidate, options)) return candidate;
}
}
return null;
}
export function tail(value: string, max = 1200): string {
const clean = value.trim();
return clean.length > max ? clean.slice(clean.length - max) : clean;
}
export function failureMessage(result: RunCommandResult, fallback: string): string {
const detail = tail(result.stderr || result.stdout);
return detail ? `${fallback}: ${detail}` : fallback;
}
function createDefaultRunCommand(): RunCommand {
return (command, args, options = {}) =>
new Promise((resolve) => {
const child = spawn(command, args, {
env: options.env ?? process.env,
windowsHide: false,
});
let stdout = '';
let stderr = '';
const timeout = setTimeout(() => {
child.kill();
}, options.timeoutMs ?? 15_000);
child.stdout?.on('data', (chunk) => {
stdout = tail(stdout + String(chunk), 4000);
});
child.stderr?.on('data', (chunk) => {
stderr = tail(stderr + String(chunk), 4000);
});
child.on('error', (error) => {
clearTimeout(timeout);
resolve({ exitCode: 1, stdout, stderr: error.message });
});
child.on('close', (code) => {
clearTimeout(timeout);
resolve({ exitCode: code, stdout, stderr });
});
});
}
export function getRunCommand(options: CommonOptions): RunCommand {
return options.runCommand ?? createDefaultRunCommand();
}
@@ -0,0 +1,101 @@
import os from 'node:os';
import path from 'node:path';
import {
envOf,
getRunCommand,
pathEntriesContain,
splitPath,
type CommonOptions,
type WindowsPathOptions,
} from './command-line-launcher-deps';
const WINDOWS_PATH_MAX = 32767;
export function windowsLauncherPaths(options: CommonOptions & WindowsPathOptions): {
binDir: string;
installPath: string;
} {
const localAppData = options.localAppData ?? envOf(options).LOCALAPPDATA;
const base = localAppData ?? path.win32.join(options.homeDir ?? os.homedir(), 'AppData', 'Local');
const binDir = path.win32.join(base, 'SubMiner', 'bin');
return { binDir, installPath: path.win32.join(binDir, 'subminer.cmd') };
}
export function windowsShimContent(appExePath: string, launcherResourcePath: string): string {
return [
'@echo off',
'setlocal',
`set "SUBMINER_BINARY_PATH=${appExePath}"`,
`bun "${launcherResourcePath}" %*`,
'',
].join('\r\n');
}
export function shimMatchesCurrentInstall(
content: string,
appExePath: string,
launcherResourcePath: string,
): boolean {
const normalized = content.replaceAll('/', '\\').toLowerCase();
return (
normalized.includes(appExePath.replaceAll('/', '\\').toLowerCase()) &&
normalized.includes(launcherResourcePath.replaceAll('/', '\\').toLowerCase())
);
}
export function getUserPath(options: CommonOptions & WindowsPathOptions): string {
return options.getUserPath?.() ?? envOf(options).Path ?? envOf(options).PATH ?? '';
}
async function setWindowsUserPath(
options: CommonOptions & WindowsPathOptions,
nextPath: string,
) {
if (options.setUserPath) {
await options.setUserPath(nextPath);
return;
}
const escaped = nextPath.replaceAll("'", "''");
await getRunCommand(options)('powershell', [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
`[Environment]::SetEnvironmentVariable('Path', '${escaped}', 'User')`,
]);
}
async function broadcastEnvironmentChange(options: CommonOptions & WindowsPathOptions) {
if (options.broadcastEnvironmentChange) {
await options.broadcastEnvironmentChange();
return;
}
await getRunCommand(options)('powershell', [
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
"$signature='[DllImport(\"user32.dll\",SetLastError=true,CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd,uint Msg,UIntPtr wParam,string lParam,uint fuFlags,uint uTimeout,out UIntPtr lpdwResult);'; Add-Type -MemberDefinition $signature -Name NativeMethods -Namespace Win32; $result=[UIntPtr]::Zero; [Win32.NativeMethods]::SendMessageTimeout([IntPtr]0xffff,0x1A,[UIntPtr]::Zero,'Environment',2,5000,[ref]$result) | Out-Null",
]);
}
export async function appendWindowsUserPathDir(
dir: string,
options: CommonOptions & WindowsPathOptions,
): Promise<string | null> {
const current = getUserPath(options);
const entries = splitPath(current, 'win32');
if (pathEntriesContain(entries, dir, 'win32')) return null;
const next = current.trim() ? `${current};${dir}` : dir;
if (next.length > WINDOWS_PATH_MAX) {
throw new Error('User PATH is too long to append the SubMiner launcher directory safely.');
}
await setWindowsUserPath(options, next);
await broadcastEnvironmentChange(options);
return next;
}
export function defaultBunRepairPath(options: CommonOptions & WindowsPathOptions): string {
const userProfile = options.userProfile ?? envOf(options).USERPROFILE ?? options.homeDir ?? os.homedir();
return path.win32.join(userProfile, '.bun', 'bin');
}
@@ -0,0 +1,269 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import {
detectBun,
detectLauncher,
installLauncher,
resolveBunInstallCommand,
resolveLauncherInstallTarget,
type BunSnapshot,
} from './command-line-launcher';
function createBunSnapshot(status: BunSnapshot['status']): BunSnapshot {
return {
status,
commandPath: status === 'ready' ? '/bin/bun' : null,
version: status === 'ready' ? '1.3.0' : null,
installMethod: null,
installCommand: null,
message: null,
};
}
test('detectBun reports ready when bun --version succeeds on PATH', async () => {
const snapshot = await detectBun({
platform: 'linux',
env: { PATH: '/usr/local/bin:/usr/bin' },
existsSync: (candidate) => candidate === '/usr/local/bin/bun',
accessSync: (candidate) => {
if (candidate !== '/usr/local/bin/bun') throw new Error('not executable');
},
runCommand: async (command, args) => {
assert.equal(command, '/usr/local/bin/bun');
assert.deepEqual(args, ['--version']);
return { exitCode: 0, stdout: '1.3.5\n', stderr: '' };
},
});
assert.deepEqual(snapshot, {
status: 'ready',
commandPath: '/usr/local/bin/bun',
version: '1.3.5',
installMethod: null,
installCommand: null,
message: null,
});
});
test('detectBun reports missing with an install command when bun is absent', async () => {
const snapshot = await detectBun({
platform: 'linux',
env: { PATH: '/usr/bin' },
existsSync: () => false,
accessSync: () => {
throw new Error('missing');
},
runCommand: async () => ({ exitCode: 127, stdout: '', stderr: 'missing' }),
});
assert.equal(snapshot.status, 'missing');
assert.equal(snapshot.commandPath, null);
assert.deepEqual(snapshot.installCommand, [
'bash',
'-lc',
'curl -fsSL https://bun.com/install | bash',
]);
});
test('resolveBunInstallCommand prefers winget on Windows', () => {
assert.deepEqual(
resolveBunInstallCommand({
platform: 'win32',
env: { PATH: 'C:\\Tools' },
existsSync: (candidate) => candidate === 'C:\\Tools\\winget.exe',
}),
[
'C:\\Tools\\winget.exe',
'install',
'--id',
'Oven-sh.Bun',
'--exact',
'--accept-package-agreements',
'--accept-source-agreements',
],
);
});
test('resolveBunInstallCommand falls back to scoop on Windows before official installer', () => {
assert.deepEqual(
resolveBunInstallCommand({
platform: 'win32',
env: { PATH: 'C:\\Tools' },
existsSync: (candidate) => candidate === 'C:\\Tools\\scoop.cmd',
}),
['C:\\Tools\\scoop.cmd', 'install', 'bun'],
);
});
test('resolveBunInstallCommand uses Homebrew on macOS when available', () => {
assert.deepEqual(
resolveBunInstallCommand({
platform: 'darwin',
env: { PATH: '/opt/homebrew/bin:/usr/bin' },
existsSync: (candidate) => candidate === '/opt/homebrew/bin/brew',
accessSync: (candidate) => {
if (candidate !== '/opt/homebrew/bin/brew') throw new Error('not executable');
},
}),
['/opt/homebrew/bin/brew', 'install', 'bun'],
);
});
test('resolveLauncherInstallTarget prefers writable user bin on Linux', async () => {
const target = await resolveLauncherInstallTarget({
platform: 'linux',
homeDir: '/home/tester',
env: { PATH: '/usr/bin:/home/tester/.local/bin:/tmp/bin' },
existsSync: (candidate) =>
candidate === '/usr/bin' ||
candidate === '/home/tester/.local/bin' ||
candidate === '/tmp/bin',
accessSync: (candidate) => {
if (candidate !== '/home/tester/.local/bin') throw new Error('not writable');
},
});
assert.equal(target.status, 'not_installed');
assert.equal(target.pathDir, '/home/tester/.local/bin');
assert.equal(target.installPath, '/home/tester/.local/bin/subminer');
});
test('resolveLauncherInstallTarget returns not_installable without writable PATH dirs', async () => {
const target = await resolveLauncherInstallTarget({
platform: 'linux',
homeDir: '/home/tester',
env: { PATH: '/usr/bin' },
existsSync: (candidate) => candidate === '/usr/bin',
accessSync: () => {
throw new Error('not writable');
},
});
assert.equal(target.status, 'not_installable');
assert.equal(target.installPath, null);
});
test('installLauncher writes Windows cmd shim and appends user PATH once', async () => {
const files = new Map<string, string>();
const dirs = new Set<string>();
const launcherResource = 'C:\\Apps\\SubMiner\\resources\\launcher\\subminer';
files.set(launcherResource, 'launcher');
let userPath = 'C:\\Tools;C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin';
let setPathCalls = 0;
const snapshot = await installLauncher({
platform: 'win32',
localAppData: 'C:\\Users\\tester\\AppData\\Local',
appExePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
launcherResourcePath: launcherResource,
env: { PATH: userPath },
existsSync: (candidate) => files.has(candidate) || dirs.has(candidate),
mkdirSync: (candidate) => dirs.add(candidate),
readFileSync: (candidate) => files.get(candidate) ?? '',
writeFileSync: (candidate, content) => files.set(candidate, content),
getUserPath: () => userPath,
setUserPath: (next) => {
setPathCalls += 1;
userPath = next;
},
broadcastEnvironmentChange: () => undefined,
runCommand: async (command, args) => {
if (command.endsWith('subminer.cmd') && args[0] === '--help') {
return { exitCode: 0, stdout: 'help', stderr: '' };
}
return { exitCode: 1, stdout: '', stderr: 'unexpected' };
},
bunSnapshot: createBunSnapshot('ready'),
});
const shimPath = 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin\\subminer.cmd';
assert.equal(setPathCalls, 0);
assert.match(
files.get(shimPath) ?? '',
/set "SUBMINER_BINARY_PATH=C:\\Apps\\SubMiner\\SubMiner\.exe"/,
);
assert.match(
files.get(shimPath) ?? '',
/bun "C:\\Apps\\SubMiner\\resources\\launcher\\subminer" %\*/,
);
assert.equal(snapshot.status, 'ready');
});
test('detectLauncher reports shadowed when another subminer appears earlier on PATH', async () => {
const snapshot = await detectLauncher({
platform: 'linux',
homeDir: '/home/tester',
env: { PATH: '/tmp/bin:/home/tester/.local/bin' },
existsSync: (candidate) =>
candidate === '/tmp/bin' ||
candidate === '/home/tester/.local/bin' ||
candidate === '/tmp/bin/subminer' ||
candidate === '/home/tester/.local/bin/subminer',
accessSync: () => undefined,
bunSnapshot: createBunSnapshot('ready'),
});
assert.equal(snapshot.status, 'shadowed');
assert.equal(snapshot.shadowedBy, '/tmp/bin/subminer');
assert.equal(snapshot.installPath, '/home/tester/.local/bin/subminer');
});
test('detectLauncher reports installed_bun_missing when launcher exists but bun is missing', async () => {
const snapshot = await detectLauncher({
platform: 'linux',
homeDir: '/home/tester',
env: { PATH: '/home/tester/.local/bin' },
existsSync: (candidate) =>
candidate === '/home/tester/.local/bin' || candidate === '/home/tester/.local/bin/subminer',
accessSync: () => undefined,
bunSnapshot: createBunSnapshot('missing'),
});
assert.equal(snapshot.status, 'installed_bun_missing');
});
test('detectLauncher treats stale Windows shim as not installed', async () => {
const shimPath = 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin\\subminer.cmd';
const snapshot = await detectLauncher({
platform: 'win32',
localAppData: 'C:\\Users\\tester\\AppData\\Local',
appExePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
launcherResourcePath: 'C:\\Apps\\SubMiner\\resources\\launcher\\subminer',
env: { PATH: 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin' },
existsSync: (candidate) =>
candidate === shimPath || candidate === 'C:\\Users\\tester\\AppData\\Local\\SubMiner\\bin',
readFileSync: () =>
'@echo off\nset "SUBMINER_BINARY_PATH=C:\\Old\\SubMiner.exe"\nbun "C:\\Old\\launcher\\subminer" %*\n',
bunSnapshot: createBunSnapshot('ready'),
});
assert.equal(snapshot.status, 'not_installed');
assert.match(snapshot.message ?? '', /previous SubMiner install/);
});
test('installLauncher copies packaged launcher and chmods on POSIX', async () => {
const files = new Map<string, string>([['/resources/launcher/subminer', 'launcher']]);
const modes = new Map<string, number>();
const snapshot = await installLauncher({
platform: 'linux',
homeDir: '/home/tester',
env: { PATH: '/home/tester/.local/bin' },
launcherResourcePath: '/resources/launcher/subminer',
existsSync: (candidate) => files.has(candidate) || candidate === '/home/tester/.local/bin',
accessSync: () => undefined,
copyFileSync: (from, to) => files.set(to, files.get(from) ?? ''),
chmodSync: (candidate, mode) => modes.set(candidate, mode),
runCommand: async (command, args) => {
assert.equal(command, '/home/tester/.local/bin/subminer');
assert.deepEqual(args, ['--help']);
return { exitCode: 0, stdout: 'help', stderr: '' };
},
bunSnapshot: createBunSnapshot('ready'),
});
assert.equal(files.get('/home/tester/.local/bin/subminer'), 'launcher');
assert.equal(modes.get('/home/tester/.local/bin/subminer'), 0o755);
assert.equal(snapshot.status, 'ready');
});
+444
View File
@@ -0,0 +1,444 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
accessSyncOf,
envOf,
existsSyncOf,
failureMessage,
findCommand,
getRunCommand,
normalizePathForCompare,
pathEntriesContain,
pathModuleFor,
platformOf,
splitPath,
type CommonOptions,
type WindowsPathOptions,
} from './command-line-launcher-deps';
import {
appendWindowsUserPathDir,
defaultBunRepairPath,
shimMatchesCurrentInstall,
windowsLauncherPaths,
windowsShimContent,
} from './command-line-launcher-windows';
export type { RunCommand, RunCommandResult } from './command-line-launcher-deps';
export type ToolStatus = 'ready' | 'missing' | 'installing' | 'failed';
export type LauncherInstallStatus =
| 'ready'
| 'installed_bun_missing'
| 'not_installed'
| 'not_on_path'
| 'shadowed'
| 'not_installable'
| 'failed';
export interface BunSnapshot {
status: ToolStatus;
commandPath: string | null;
version: string | null;
installMethod: 'winget' | 'scoop' | 'homebrew' | 'official-script' | null;
installCommand: string[] | null;
message: string | null;
}
export interface LauncherSnapshot {
status: LauncherInstallStatus;
commandPath: string | null;
installPath: string | null;
pathDir: string | null;
shadowedBy: string | null;
message: string | null;
}
export interface CommandLineLauncherSnapshot {
supported: boolean;
bun: BunSnapshot;
launcher: LauncherSnapshot;
}
const BUN_OFFICIAL_POSIX_COMMAND = ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'];
const BUN_OFFICIAL_WINDOWS_COMMAND = [
'powershell',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
'irm bun.sh/install.ps1 | iex',
];
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
const COMMAND_TIMEOUT_MS = 15 * 1000;
function installMethodForCommand(
command: string[] | null,
): BunSnapshot['installMethod'] {
if (!command) return null;
const executablePath = command[0];
if (!executablePath) return null;
const executable = path.win32.basename(executablePath).toLowerCase();
if (executable === 'winget.exe') return 'winget';
if (executable === 'scoop.cmd') return 'scoop';
if (executable === 'brew') return 'homebrew';
return 'official-script';
}
export function resolveBunInstallCommand(options: CommonOptions = {}): BunSnapshot['installCommand'] {
const platform = platformOf(options);
if (platform === 'win32') {
const winget = findCommand('winget.exe', options);
if (winget) {
return [
winget,
'install',
'--id',
'Oven-sh.Bun',
'--exact',
'--accept-package-agreements',
'--accept-source-agreements',
];
}
const scoop = findCommand('scoop.cmd', options);
if (scoop) return [scoop, 'install', 'bun'];
return BUN_OFFICIAL_WINDOWS_COMMAND;
}
const brew = findCommand('brew', options);
if (platform === 'darwin' && brew) return [brew, 'install', 'bun'];
if (platform === 'linux' && brew) return [brew, 'install', 'bun'];
return BUN_OFFICIAL_POSIX_COMMAND;
}
export async function detectBun(options: CommonOptions = {}): Promise<BunSnapshot> {
const bunPath = findCommand('bun', options);
const installCommand = resolveBunInstallCommand(options);
if (!bunPath) {
return {
status: 'missing',
commandPath: null,
version: null,
installMethod: installMethodForCommand(installCommand),
installCommand,
message: null,
};
}
const result = await getRunCommand(options)(bunPath, ['--version'], {
timeoutMs: COMMAND_TIMEOUT_MS,
env: envOf(options) as NodeJS.ProcessEnv,
});
if (result.exitCode === 0) {
return {
status: 'ready',
commandPath: bunPath,
version: result.stdout.trim() || null,
installMethod: null,
installCommand: null,
message: null,
};
}
return {
status: 'failed',
commandPath: bunPath,
version: null,
installMethod: installMethodForCommand(installCommand),
installCommand,
message: failureMessage(result, 'bun --version failed'),
};
}
function resolveLauncherResourcePath(options: CommonOptions): string {
const platformPath = pathModuleFor(platformOf(options));
if (options.launcherResourcePath) return options.launcherResourcePath;
const resourcesPath = options.resourcesPath ?? (process as typeof process & { resourcesPath?: string }).resourcesPath;
const packaged = resourcesPath ? platformPath.join(resourcesPath, 'launcher', 'subminer') : null;
if (packaged && existsSyncOf(options)(packaged)) return packaged;
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer');
}
function isWritableDir(candidate: string, options: CommonOptions): boolean {
try {
if (!existsSyncOf(options)(candidate)) return false;
accessSyncOf(options)(candidate, fs.constants.W_OK);
return true;
} catch {
return false;
}
}
function collectPathDirs(options: CommonOptions): string[] {
const platform = platformOf(options);
const dirs: string[] = [];
const add = (dir: string) => {
if (!pathEntriesContain(dirs, dir, platform)) dirs.push(dir);
};
splitPath(envOf(options).PATH, platform).forEach(add);
return dirs;
}
export async function resolveLauncherInstallTarget(
options: CommonOptions & WindowsPathOptions = {},
): Promise<LauncherSnapshot> {
const platform = platformOf(options);
if (platform === 'win32') {
const { binDir, installPath } = windowsLauncherPaths(options);
return {
status: existsSyncOf(options)(installPath) ? 'ready' : 'not_installed',
commandPath: existsSyncOf(options)(installPath) ? installPath : null,
installPath,
pathDir: binDir,
shadowedBy: null,
message: null,
};
}
const homeDir = options.homeDir ?? os.homedir();
const pathDirs = collectPathDirs(options);
const preferred =
platform === 'darwin'
? [
'/opt/homebrew/bin',
'/usr/local/bin',
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
]
: [path.posix.join(homeDir, '.local', 'bin'), path.posix.join(homeDir, 'bin'), '/usr/local/bin'];
const candidates = [...preferred, ...pathDirs].filter((dir, index, all) =>
all.findIndex((other) => normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform)) === index,
);
const selected = candidates.find((dir) => pathEntriesContain(pathDirs, dir, platform) && isWritableDir(dir, options));
if (!selected) {
return {
status: 'not_installable',
commandPath: null,
installPath: null,
pathDir: null,
shadowedBy: null,
message: 'No writable directory was found on your command-line PATH.',
};
}
const installPath = path.posix.join(selected, 'subminer');
return {
status: existsSyncOf(options)(installPath) ? 'ready' : 'not_installed',
commandPath: existsSyncOf(options)(installPath) ? installPath : null,
installPath,
pathDir: selected,
shadowedBy: null,
message: null,
};
}
export async function detectLauncher(
options: CommonOptions & WindowsPathOptions & { bunSnapshot?: BunSnapshot } = {},
): Promise<LauncherSnapshot> {
const platform = platformOf(options);
const target = await resolveLauncherInstallTarget(options);
if (target.status === 'not_installable') return target;
const expectedPath = target.installPath;
if (!expectedPath) return target;
const platformPath = pathModuleFor(platform);
const launcherResourcePath = resolveLauncherResourcePath(options);
const appExePath = options.appExePath ?? process.execPath;
if (platform === 'win32' && existsSyncOf(options)(expectedPath)) {
const content = String((options.readFileSync ?? fs.readFileSync)(expectedPath, 'utf8'));
if (!shimMatchesCurrentInstall(content, appExePath, launcherResourcePath)) {
return {
...target,
status: 'not_installed',
commandPath: null,
message: 'Installed launcher points at a previous SubMiner install; reinstall to refresh.',
};
}
}
const commandPath = findCommand('subminer', options);
const expectedNormalized = normalizePathForCompare(expectedPath, platform, platformPath);
if (commandPath && normalizePathForCompare(commandPath, platform, platformPath) !== expectedNormalized) {
return { ...target, status: 'shadowed', commandPath: expectedPath, shadowedBy: commandPath };
}
if (!existsSyncOf(options)(expectedPath)) return { ...target, status: 'not_installed', commandPath: null };
if (!commandPath) {
return {
...target,
status: 'not_on_path',
commandPath: expectedPath,
message: 'Launcher exists but its directory is not on PATH.',
};
}
const bunSnapshot = options.bunSnapshot ?? (await detectBun(options));
if (bunSnapshot.status !== 'ready') {
return {
...target,
status: 'installed_bun_missing',
commandPath,
message: 'Launcher is installed, but Bun is missing. Install Bun, then open a new terminal.',
};
}
const result = await getRunCommand(options)(commandPath, ['--help'], {
timeoutMs: COMMAND_TIMEOUT_MS,
env: envOf(options) as NodeJS.ProcessEnv,
});
if (result.exitCode !== 0) {
return {
...target,
status: 'failed',
commandPath,
message: failureMessage(result, 'subminer --help failed'),
};
}
return { ...target, status: 'ready', commandPath, message: null };
}
export async function installLauncher(
options: CommonOptions & WindowsPathOptions & { bunSnapshot?: BunSnapshot } = {},
): Promise<LauncherSnapshot> {
const platform = platformOf(options);
const target = await resolveLauncherInstallTarget(options);
if (!target.installPath || !target.pathDir) return target;
const launcherResourcePath = resolveLauncherResourcePath(options);
if (!existsSyncOf(options)(launcherResourcePath)) {
return {
...target,
status: 'failed',
message: `Packaged launcher resource is missing: ${launcherResourcePath}`,
};
}
if (platform === 'win32') {
(options.mkdirSync ?? fs.mkdirSync)(target.pathDir, { recursive: true });
(options.writeFileSync ?? fs.writeFileSync)(
target.installPath,
windowsShimContent(options.appExePath ?? process.execPath, launcherResourcePath),
'utf8',
);
try {
const nextPath = await appendWindowsUserPathDir(target.pathDir, options);
if (nextPath && options.env) {
options.env.PATH = nextPath;
options.env.Path = nextPath;
}
} catch (error) {
return {
...target,
status: 'failed',
message: error instanceof Error ? error.message : String(error),
};
}
} else {
(options.copyFileSync ?? fs.copyFileSync)(launcherResourcePath, target.installPath);
(options.chmodSync ?? fs.chmodSync)(target.installPath, 0o755);
}
return detectLauncher(options);
}
export async function installBun(
options: CommonOptions & WindowsPathOptions = {},
): Promise<BunSnapshot> {
const platform = platformOf(options);
if (platform === 'win32') {
const bunDir = defaultBunRepairPath(options);
const bunExe = path.win32.join(bunDir, 'bun.exe');
if (existsSyncOf(options)(bunExe) && !findCommand('bun.exe', options)) {
try {
await appendWindowsUserPathDir(bunDir, options);
return {
status: 'ready',
commandPath: bunExe,
version: null,
installMethod: null,
installCommand: null,
message: 'Bun PATH repaired. Open a new terminal.',
};
} catch (error) {
return {
status: 'failed',
commandPath: bunExe,
version: null,
installMethod: null,
installCommand: null,
message: error instanceof Error ? error.message : String(error),
};
}
}
}
const installCommand = resolveBunInstallCommand(options);
if (!installCommand || installCommand.length === 0) {
return {
status: 'missing',
commandPath: null,
version: null,
installMethod: null,
installCommand: null,
message: 'No Bun install command is available for this platform.',
};
}
const command = installCommand[0]!;
const args = installCommand.slice(1);
const result = await getRunCommand(options)(command, args, {
timeoutMs: INSTALL_TIMEOUT_MS,
env: envOf(options) as NodeJS.ProcessEnv,
});
if (result.exitCode !== 0) {
return {
status: 'failed',
commandPath: null,
version: null,
installMethod: installMethodForCommand(installCommand),
installCommand,
message: failureMessage(result, 'Bun install failed'),
};
}
const detected = await detectBun(options);
if (detected.status === 'ready') {
return { ...detected, message: 'Bun installed. Open a new terminal.' };
}
return {
...detected,
status: 'missing',
message:
platform === 'win32'
? 'Bun installed, but this process cannot see it on PATH yet. Open a new terminal.'
: 'Bun installed, but is not on PATH for this shell. Add ~/.bun/bin to PATH if needed.',
};
}
export async function detectCommandLineLauncher(
options: CommonOptions & WindowsPathOptions = {},
): Promise<CommandLineLauncherSnapshot> {
const platform = platformOf(options);
const supported = platform === 'win32' || platform === 'linux' || platform === 'darwin';
if (!supported) {
return {
supported: false,
bun: {
status: 'missing',
commandPath: null,
version: null,
installMethod: null,
installCommand: null,
message: 'Command-line launcher setup is not supported on this platform.',
},
launcher: {
status: 'not_installable',
commandPath: null,
installPath: null,
pathDir: null,
shadowedBy: null,
message: 'Command-line launcher setup is not supported on this platform.',
},
};
}
const bun = await detectBun(options);
const launcher = await detectLauncher({ ...options, bunSnapshot: bun });
return { supported, bun, launcher };
}
@@ -47,6 +47,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
}),
runJellyfinCommand: async () => {},
runStatsCommand: async () => {},
runUpdateCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {},
cycleSecondarySubMode: () => {},
@@ -5,6 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import { createFirstRunSetupService, shouldAutoOpenFirstRunSetup } from './first-run-setup-service';
import type { CliArgs } from '../../cli/args';
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> | void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-first-run-service-test-'));
@@ -90,6 +91,31 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
};
}
function createCommandLineLauncherSnapshot(
overrides: Partial<CommandLineLauncherSnapshot> = {},
): CommandLineLauncherSnapshot {
return {
supported: true,
bun: {
status: 'missing',
commandPath: null,
version: null,
installMethod: 'official-script',
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
message: null,
},
launcher: {
status: 'not_installed',
commandPath: null,
installPath: '/home/tester/.local/bin/subminer',
pathDir: '/home/tester/.local/bin',
shadowedBy: null,
message: null,
},
...overrides,
};
}
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
@@ -514,6 +540,141 @@ test('setup service persists Windows mpv shortcut preferences and status with on
});
});
test('setup service snapshot includes command-line launcher status', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
const commandLineLauncher = createCommandLineLauncherSnapshot({
bun: {
status: 'ready',
commandPath: '/usr/local/bin/bun',
version: '1.3.5',
installMethod: null,
installCommand: null,
message: null,
},
});
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 0,
detectPluginInstalled: () => true,
detectCommandLineLauncher: async () => commandLineLauncher,
onStateChanged: () => undefined,
});
const snapshot = await service.refreshStatus();
assert.deepEqual(snapshot.commandLineLauncher, commandLineLauncher);
});
});
test('setup service installBun persists installed and failed status', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let installOk = true;
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => true,
detectCommandLineLauncher: async () => createCommandLineLauncherSnapshot(),
installBun: async () => ({
ok: installOk,
message: installOk ? 'Bun installed. Open a new terminal.' : 'Bun install failed.',
}),
onStateChanged: () => undefined,
});
const installed = await service.installBun();
assert.equal(installed.state.bunInstallStatus, 'installed');
assert.equal(installed.canFinish, true);
assert.equal(installed.message, 'Bun installed. Open a new terminal.');
installOk = false;
const failed = await service.installBun();
assert.equal(failed.state.bunInstallStatus, 'failed');
assert.equal(failed.canFinish, true);
assert.equal(failed.message, 'Bun install failed.');
});
});
test('setup service installCommandLineLauncher persists status and path', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
let installOk = true;
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => true,
detectCommandLineLauncher: async () => createCommandLineLauncherSnapshot(),
installCommandLineLauncher: async () => ({
ok: installOk,
installPath: installOk ? '/home/tester/.local/bin/subminer' : null,
message: installOk ? 'Launcher installed.' : 'Launcher install failed.',
}),
onStateChanged: () => undefined,
});
const installed = await service.installCommandLineLauncher();
assert.equal(installed.state.launcherInstallStatus, 'installed');
assert.equal(installed.state.launcherInstallPath, '/home/tester/.local/bin/subminer');
assert.equal(installed.canFinish, true);
installOk = false;
const failed = await service.installCommandLineLauncher();
assert.equal(failed.state.launcherInstallStatus, 'failed');
assert.equal(failed.state.launcherInstallPath, null);
assert.equal(failed.canFinish, true);
});
});
test('setup completion is unaffected by missing or failed command-line launcher setup', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}');
const service = createFirstRunSetupService({
configDir,
getYomitanDictionaryCount: async () => 1,
detectPluginInstalled: () => true,
detectCommandLineLauncher: async () =>
createCommandLineLauncherSnapshot({
bun: {
status: 'failed',
commandPath: null,
version: null,
installMethod: 'official-script',
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
message: 'Bun install failed.',
},
launcher: {
status: 'failed',
commandPath: null,
installPath: '/home/tester/.local/bin/subminer',
pathDir: '/home/tester/.local/bin',
shadowedBy: null,
message: 'Launcher install failed.',
},
}),
onStateChanged: () => undefined,
});
const initial = await service.ensureSetupStateInitialized();
assert.equal(initial.canFinish, true);
const completed = await service.markSetupCompleted();
assert.equal(completed.state.status, 'completed');
assert.equal(completed.canFinish, true);
});
});
test('setup service removes legacy mpv plugin candidates and refreshes detection', async () => {
await withTempDir(async (root) => {
const configDir = path.join(root, 'SubMiner');
@@ -15,6 +15,7 @@ import type {
InstalledFirstRunPluginCandidate,
LegacyMpvPluginRemovalResult,
} from './first-run-setup-plugin';
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
export interface SetupWindowsMpvShortcutSnapshot {
supported: boolean;
@@ -35,6 +36,7 @@ export interface SetupStatusSnapshot {
pluginInstallPathSummary: string | null;
legacyMpvPluginPaths: string[];
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
commandLineLauncher: CommandLineLauncherSnapshot;
message: string | null;
state: SetupState;
}
@@ -58,6 +60,8 @@ export interface FirstRunSetupService {
startMenuEnabled: boolean;
desktopEnabled: boolean;
}) => Promise<SetupStatusSnapshot>;
installBun: () => Promise<SetupStatusSnapshot>;
installCommandLineLauncher: () => Promise<SetupStatusSnapshot>;
isSetupCompleted: () => boolean;
}
@@ -172,6 +176,28 @@ function isYomitanSetupSatisfied(options: {
return options.externalYomitanConfigured || options.dictionaryCount >= 1;
}
function createUnsupportedCommandLineLauncherSnapshot(): CommandLineLauncherSnapshot {
return {
supported: false,
bun: {
status: 'missing',
commandPath: null,
version: null,
installMethod: null,
installCommand: null,
message: 'Command-line launcher setup is unavailable in this runtime.',
},
launcher: {
status: 'not_installable',
commandPath: null,
installPath: null,
pathDir: null,
shadowedBy: null,
message: 'Command-line launcher setup is unavailable in this runtime.',
},
};
}
export function getFirstRunSetupCompletionMessage(snapshot: {
configReady: boolean;
dictionaryCount: number;
@@ -235,6 +261,15 @@ export function createFirstRunSetupService(deps: {
startMenuEnabled: boolean;
desktopEnabled: boolean;
}) => Promise<{ ok: boolean; status: SetupWindowsMpvShortcutInstallStatus; message: string }>;
detectCommandLineLauncher?: () =>
| CommandLineLauncherSnapshot
| Promise<CommandLineLauncherSnapshot>;
installBun?: () => Promise<{ ok: boolean; message: string }>;
installCommandLineLauncher?: () => Promise<{
ok: boolean;
installPath: string | null;
message: string;
}>;
onStateChanged?: (state: SetupState) => void;
}): FirstRunSetupService {
const setupStatePath = getSetupStatePath(deps.configDir);
@@ -262,6 +297,8 @@ export function createFirstRunSetupService(deps: {
const detectedWindowsMpvShortcuts = isWindows
? await deps.detectWindowsMpvShortcuts?.()
: undefined;
const commandLineLauncher =
(await deps.detectCommandLineLauncher?.()) ?? createUnsupportedCommandLineLauncherSnapshot();
const installedWindowsMpvShortcuts = {
startMenuInstalled: detectedWindowsMpvShortcuts?.startMenuInstalled ?? false,
desktopInstalled: detectedWindowsMpvShortcuts?.desktopInstalled ?? false,
@@ -291,6 +328,7 @@ export function createFirstRunSetupService(deps: {
status: getWindowsMpvShortcutStatus(state, installedWindowsMpvShortcuts),
message: null,
},
commandLineLauncher,
message,
state,
} satisfies SetupStatusSnapshot;
@@ -453,6 +491,36 @@ export function createFirstRunSetupService(deps: {
result.message,
);
},
installBun: async () => {
if (!deps.installBun) {
return refreshWithState(readState(), 'Bun installation is unavailable in this runtime.');
}
const result = await deps.installBun();
return refreshWithState(
writeState({
...readState(),
bunInstallStatus: result.ok ? 'installed' : 'failed',
}),
result.message,
);
},
installCommandLineLauncher: async () => {
if (!deps.installCommandLineLauncher) {
return refreshWithState(
readState(),
'Command-line launcher installation is unavailable in this runtime.',
);
}
const result = await deps.installCommandLineLauncher();
return refreshWithState(
writeState({
...readState(),
launcherInstallStatus: result.ok ? 'installed' : 'failed',
launcherInstallPath: result.ok ? result.installPath : null,
}),
result.message,
);
},
isSetupCompleted: () => completed || isSetupCompleted(readState()),
};
}
@@ -7,6 +7,32 @@ import {
createOpenFirstRunSetupWindowHandler,
parseFirstRunSetupSubmissionUrl,
} from './first-run-setup-window';
import type { CommandLineLauncherSnapshot } from './command-line-launcher';
function createCommandLineLauncherSnapshot(
overrides: Partial<CommandLineLauncherSnapshot> = {},
): CommandLineLauncherSnapshot {
return {
supported: true,
bun: {
status: 'missing',
commandPath: null,
version: null,
installMethod: 'official-script',
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
message: null,
},
launcher: {
status: 'not_installed',
commandPath: null,
installPath: '/home/tester/.local/bin/subminer',
pathDir: '/home/tester/.local/bin',
shadowedBy: null,
message: null,
},
...overrides,
};
}
test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish state', () => {
const html = buildFirstRunSetupHtml({
@@ -26,6 +52,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: 'Waiting for dictionaries',
});
@@ -58,6 +85,7 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
desktopInstalled: false,
status: 'installed',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
@@ -88,6 +116,7 @@ test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirm
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
@@ -124,6 +153,7 @@ test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', (
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
@@ -149,6 +179,7 @@ test('buildFirstRunSetupHtml explains the config blocker when setup is missing c
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
@@ -173,6 +204,7 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
});
@@ -196,6 +228,20 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh',
});
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=install-bun'),
{
action: 'install-bun',
},
);
assert.deepEqual(
parseFirstRunSetupSubmissionUrl(
'subminer://first-run-setup?action=install-command-line-launcher',
),
{
action: 'install-command-line-launcher',
},
);
assert.deepEqual(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=remove-legacy-plugin'),
{
@@ -209,6 +255,59 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
});
test('buildFirstRunSetupHtml renders command-line launcher section and actions', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot({
bun: {
status: 'failed',
commandPath: null,
version: null,
installMethod: 'official-script',
installCommand: ['bash', '-lc', 'curl -fsSL https://bun.com/install | bash'],
message: 'network failed',
},
launcher: {
status: 'installed_bun_missing',
commandPath: '/home/tester/.local/bin/subminer',
installPath: '/home/tester/.local/bin/subminer',
pathDir: '/home/tester/.local/bin',
shadowedBy: null,
message: 'Bun is missing.',
},
}),
message: null,
});
assert.match(html, /Command line launcher/);
assert.match(html, /Optional\. Setup can finish without Bun or the launcher\./);
assert.match(html, /Bun runtime/);
assert.match(html, /Failed/);
assert.match(html, /bash -lc curl -fsSL https:\/\/bun\.com\/install \| bash/);
assert.match(html, /Install Bun/);
assert.match(html, /action=install-bun/);
assert.match(html, /SubMiner launcher/);
assert.match(html, /Installed, Bun missing/);
assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/);
assert.match(html, /action=install-command-line-launcher/);
assert.match(html, /<button class="primary" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/);
});
test('first-run setup window handler focuses existing window', () => {
const calls: string[] = [];
const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({
@@ -304,6 +403,7 @@ test('closing incomplete first-run setup quits app outside background mode', asy
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
}),
buildSetupHtml: () => '<html></html>',
+146
View File
@@ -1,4 +1,9 @@
import { getFirstRunSetupCompletionMessage } from './first-run-setup-service';
import type {
BunSnapshot,
CommandLineLauncherSnapshot,
LauncherSnapshot,
} from './command-line-launcher';
type FocusableWindowLike = {
focus: () => void;
@@ -20,6 +25,8 @@ export type FirstRunSetupAction =
| 'configure-mpv-executable-path'
| 'remove-legacy-plugin'
| 'configure-windows-mpv-shortcuts'
| 'install-bun'
| 'install-command-line-launcher'
| 'open-yomitan-settings'
| 'refresh'
| 'finish';
@@ -49,6 +56,7 @@ export interface FirstRunSetupHtmlModel {
desktopInstalled: boolean;
status: 'installed' | 'optional' | 'skipped' | 'failed';
};
commandLineLauncher: CommandLineLauncherSnapshot;
message: string | null;
}
@@ -64,6 +72,125 @@ function renderStatusBadge(value: string, tone: 'ready' | 'warn' | 'muted' | 'da
return `<span class="badge ${tone}">${escapeHtml(value)}</span>`;
}
function formatCommand(command: string[] | null): string {
return command?.join(' ') ?? 'No install command detected';
}
function getBunStatusLabel(status: BunSnapshot['status']): string {
switch (status) {
case 'ready':
return 'Ready';
case 'installing':
return 'Installing';
case 'failed':
return 'Failed';
case 'missing':
return 'Missing';
}
}
function getLauncherStatusLabel(status: LauncherSnapshot['status']): string {
switch (status) {
case 'ready':
return 'Ready';
case 'installed_bun_missing':
return 'Installed, Bun missing';
case 'not_installed':
return 'Not installed';
case 'not_on_path':
return 'Not on PATH';
case 'shadowed':
return 'Shadowed';
case 'not_installable':
return 'Not installable';
case 'failed':
return 'Failed';
}
}
function getToolTone(status: BunSnapshot['status']): 'ready' | 'warn' | 'muted' | 'danger' {
if (status === 'ready') return 'ready';
if (status === 'failed') return 'danger';
if (status === 'installing') return 'muted';
return 'warn';
}
function getLauncherTone(
status: LauncherSnapshot['status'],
): 'ready' | 'warn' | 'muted' | 'danger' {
if (status === 'ready') return 'ready';
if (status === 'failed') return 'danger';
if (status === 'installed_bun_missing' || status === 'not_installed') return 'warn';
return 'muted';
}
function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLauncherSnapshot): string {
if (!commandLineLauncher.supported) {
return '';
}
const bun = commandLineLauncher.bun;
const launcher = commandLineLauncher.launcher;
const bunMeta =
bun.status === 'ready'
? [
bun.commandPath ? `Path: ${bun.commandPath}` : null,
bun.version ? `Version: ${bun.version}` : null,
].filter(Boolean)
: [
bun.installMethod ? `Method: ${bun.installMethod}` : null,
`Command: ${formatCommand(bun.installCommand)}`,
bun.message,
].filter(Boolean);
const launcherMeta = [
launcher.commandPath ? `Command: ${launcher.commandPath}` : null,
launcher.installPath ? `Install target: ${launcher.installPath}` : null,
launcher.pathDir ? `PATH dir: ${launcher.pathDir}` : null,
launcher.shadowedBy ? `Shadowed by: ${launcher.shadowedBy}` : null,
launcher.message,
bun.status !== 'ready' ? 'Warning: subminer will not run until Bun is available.' : null,
].filter(Boolean);
const bunInstallButton =
bun.status === 'missing' || bun.status === 'failed'
? `<button onclick="window.location.href='subminer://first-run-setup?action=install-bun'">Install Bun</button>`
: '';
const launcherButtonDisabled = launcher.status === 'failed' ? '' : '';
return `
<section class="setup-section">
<div class="section-head">
<h2>Command line launcher</h2>
<div class="meta">Optional. Setup can finish without Bun or the launcher.</div>
</div>
<div class="card block">
<div class="card-head">
<div>
<strong>Bun runtime</strong>
${bunMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
</div>
${renderStatusBadge(getBunStatusLabel(bun.status), getToolTone(bun.status))}
</div>
<div class="inline-actions">
${bunInstallButton}
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
</div>
</div>
<div class="card block">
<div class="card-head">
<div>
<strong>SubMiner launcher</strong>
${launcherMeta.map((line) => `<div class="meta">${escapeHtml(String(line))}</div>`).join('')}
</div>
${renderStatusBadge(getLauncherStatusLabel(launcher.status), getLauncherTone(launcher.status))}
</div>
<div class="inline-actions">
<button ${launcherButtonDisabled} onclick="window.location.href='subminer://first-run-setup?action=install-command-line-launcher'">Install launcher</button>
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh</button>
</div>
</div>
</section>`;
}
export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
const legacyMpvPluginPaths = model.legacyMpvPluginPaths ?? [];
const finishButtonLabel =
@@ -264,6 +391,16 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
gap: 8px;
margin-top: 12px;
}
.setup-section {
margin-top: 10px;
}
.section-head {
margin: 14px 0 8px;
}
.section-head h2 {
margin: 0;
font-size: 14px;
}
label {
color: var(--muted);
display: flex;
@@ -307,6 +444,12 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
gap: 8px;
margin-top: 14px;
}
.inline-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
button {
border: 0;
border-radius: 10px;
@@ -386,6 +529,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
${mpvExecutablePathCard}
${windowsShortcutCard}
${renderCommandLineLauncherSection(model.commandLineLauncher)}
${legacyPluginCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
@@ -409,6 +553,8 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
action !== 'configure-mpv-executable-path' &&
action !== 'remove-legacy-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
action !== 'install-bun' &&
action !== 'install-command-line-launcher' &&
action !== 'open-yomitan-settings' &&
action !== 'refresh' &&
action !== 'finish'
@@ -11,6 +11,9 @@ export function createBuildReloadConfigMainDepsHandler(deps: ReloadConfigMainDep
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
startConfigHotReload: () => deps.startConfigHotReload(),
shouldRefreshAnilistClientSecretState: deps.shouldRefreshAnilistClientSecretState
? () => deps.shouldRefreshAnilistClientSecretState?.() !== false
: undefined,
refreshAnilistClientSecretState: (options: { force: boolean }) =>
deps.refreshAnilistClientSecretState(options),
failHandlers: {
+30
View File
@@ -93,6 +93,36 @@ test('createReloadConfigHandler fails startup for parse errors', () => {
assert.equal(calls.includes('hotReload:start'), false);
});
test('createReloadConfigHandler can skip AniList refresh for headless commands', async () => {
const calls: string[] = [];
const reloadConfig = createReloadConfigHandler({
reloadConfigStrict: () => ({
ok: true,
path: '/tmp/config.jsonc',
warnings: [],
}),
logInfo: (message) => calls.push(`info:${message}`),
logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('hotReload:start'),
shouldRefreshAnilistClientSecretState: () => false,
refreshAnilistClientSecretState: async () => {
calls.push('refresh');
},
failHandlers: {
logError: (details) => calls.push(`error:${details}`),
showErrorBox: (title, details) => calls.push(`dialog:${title}:${details}`),
quit: () => calls.push('quit'),
},
});
reloadConfig();
await Promise.resolve();
assert.equal(calls.includes('refresh'), false);
assert.ok(calls.includes('hotReload:start'));
});
test('createCriticalConfigErrorHandler formats and fails', () => {
const calls: string[] = [];
const exitCodes: number[] = [];
+4 -1
View File
@@ -31,6 +31,7 @@ export type ReloadConfigRuntimeDeps = {
force: boolean;
allowSetupPrompt?: boolean;
}) => Promise<unknown>;
shouldRefreshAnilistClientSecretState?: () => boolean;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
@@ -75,7 +76,9 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () =>
}
deps.startConfigHotReload();
void deps.refreshAnilistClientSecretState({ force: true, allowSetupPrompt: false });
if (deps.shouldRefreshAnilistClientSecretState?.() !== false) {
void deps.refreshAnilistClientSecretState({ force: true, allowSetupPrompt: false });
}
};
}
+25 -1
View File
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldEnsureTrayOnStartupForInitialArgs } from './startup-tray-policy';
import {
shouldEnsureTrayOnStartupForInitialArgs,
shouldQuitOnWindowAllClosedForTrayState,
} from './startup-tray-policy';
test('startup tray policy enables tray on Windows by default', () => {
assert.equal(shouldEnsureTrayOnStartupForInitialArgs('win32', null), true);
@@ -18,3 +21,24 @@ test('startup tray policy skips tray for direct youtube playback on Windows', ()
test('startup tray policy skips tray outside Windows', () => {
assert.equal(shouldEnsureTrayOnStartupForInitialArgs('linux', null), false);
});
test('window-all-closed keeps tray-resident app alive', () => {
assert.equal(
shouldQuitOnWindowAllClosedForTrayState({ backgroundMode: false, hasTray: true }),
false,
);
});
test('window-all-closed quits non-background app without tray', () => {
assert.equal(
shouldQuitOnWindowAllClosedForTrayState({ backgroundMode: false, hasTray: false }),
true,
);
});
test('window-all-closed keeps background app alive without tray', () => {
assert.equal(
shouldQuitOnWindowAllClosedForTrayState({ backgroundMode: true, hasTray: false }),
false,
);
});
+9
View File
@@ -12,3 +12,12 @@ export function shouldEnsureTrayOnStartupForInitialArgs(
}
return true;
}
export function shouldQuitOnWindowAllClosedForTrayState(options: {
backgroundMode: boolean;
hasTray: boolean;
}): boolean {
if (options.backgroundMode) return false;
if (options.hasTray) return false;
return true;
}
@@ -43,6 +43,7 @@ test('build tray template handler wires actions and init guards', () => {
buildTrayMenuTemplateRuntime: (handlers) => {
handlers.openSessionHelp();
handlers.openTexthookerInBrowser();
calls.push(`show-texthooker:${handlers.showTexthookerPage}`);
handlers.openFirstRunSetup();
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
@@ -50,6 +51,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery();
handlers.openAnilistSetup();
handlers.checkForUpdates();
handlers.quitApp();
return [{ label: 'ok' }] as never;
},
@@ -60,6 +62,7 @@ test('build tray template handler wires actions and init guards', () => {
isOverlayRuntimeInitialized: () => initialized,
openSessionHelpModal: () => calls.push('help'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true,
@@ -72,6 +75,7 @@ test('build tray template handler wires actions and init guards', () => {
calls.push('jellyfin-discovery');
},
openAnilistSetupWindow: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
});
@@ -81,6 +85,7 @@ test('build tray template handler wires actions and init guards', () => {
'init',
'help',
'texthooker',
'show-texthooker:true',
'setup',
'setup',
'yomitan',
@@ -88,6 +93,7 @@ test('build tray template handler wires actions and init guards', () => {
'jellyfin',
'jellyfin-discovery',
'anilist',
'updates',
'quit',
]);
});
+8
View File
@@ -30,6 +30,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openSessionHelp: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: boolean;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
@@ -41,12 +42,14 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void;
openAnilistSetup: () => void;
checkForUpdates: () => void;
quitApp: () => void;
}) => TMenuItem[];
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
openSessionHelpModal: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: () => boolean;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
@@ -57,6 +60,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
isJellyfinDiscoveryActive: () => boolean;
toggleJellyfinDiscovery: () => void | Promise<void>;
openAnilistSetupWindow: () => void;
checkForUpdates: () => void;
quitApp: () => void;
}) {
return (): TMenuItem[] => {
@@ -70,6 +74,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openTexthookerInBrowser: () => {
deps.openTexthookerInBrowser();
},
showTexthookerPage: deps.showTexthookerPage(),
openFirstRunSetup: () => {
deps.openFirstRunSetupWindow();
},
@@ -98,6 +103,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openAnilistSetup: () => {
deps.openAnilistSetupWindow();
},
checkForUpdates: () => {
deps.checkForUpdates();
},
quitApp: () => {
deps.quitApp();
},
+4
View File
@@ -26,6 +26,7 @@ test('tray main deps builders return mapped handlers', () => {
isOverlayRuntimeInitialized: () => false,
openSessionHelpModal: () => calls.push('help'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => calls.push('setup'),
showWindowsMpvLauncherSetup: () => true,
@@ -38,12 +39,14 @@ test('tray main deps builders return mapped handlers', () => {
calls.push('jellyfin-discovery');
},
openAnilistSetupWindow: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
})();
const template = menuDeps.buildTrayMenuTemplateRuntime({
openSessionHelp: () => calls.push('open-help'),
openTexthookerInBrowser: () => calls.push('open-texthooker'),
showTexthookerPage: true,
openFirstRunSetup: () => calls.push('open-setup'),
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
@@ -55,6 +58,7 @@ test('tray main deps builders return mapped handlers', () => {
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => calls.push('open-jellyfin-discovery'),
openAnilistSetup: () => calls.push('open-anilist'),
checkForUpdates: () => calls.push('open-updates'),
quitApp: () => calls.push('quit-app'),
});
+6
View File
@@ -29,6 +29,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
buildTrayMenuTemplateRuntime: (handlers: {
openSessionHelp: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: boolean;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
@@ -40,12 +41,14 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void;
openAnilistSetup: () => void;
checkForUpdates: () => void;
quitApp: () => void;
}) => TMenuItem[];
initializeOverlayRuntime: () => void;
isOverlayRuntimeInitialized: () => boolean;
openSessionHelpModal: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: () => boolean;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
@@ -56,6 +59,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
isJellyfinDiscoveryActive: () => boolean;
toggleJellyfinDiscovery: () => void | Promise<void>;
openAnilistSetupWindow: () => void;
checkForUpdates: () => void;
quitApp: () => void;
}) {
return () => ({
@@ -64,6 +68,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
isOverlayRuntimeInitialized: deps.isOverlayRuntimeInitialized,
openSessionHelpModal: deps.openSessionHelpModal,
openTexthookerInBrowser: deps.openTexthookerInBrowser,
showTexthookerPage: deps.showTexthookerPage,
showFirstRunSetup: deps.showFirstRunSetup,
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
@@ -74,6 +79,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive,
toggleJellyfinDiscovery: deps.toggleJellyfinDiscovery,
openAnilistSetupWindow: deps.openAnilistSetupWindow,
checkForUpdates: deps.checkForUpdates,
quitApp: deps.quitApp,
});
}
@@ -26,6 +26,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
isOverlayRuntimeInitialized: () => overlayInitialized,
openSessionHelpModal: () => {},
openTexthookerInBrowser: () => {},
showTexthookerPage: () => true,
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => {},
showWindowsMpvLauncherSetup: () => true,
@@ -36,6 +37,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
isJellyfinDiscoveryActive: () => false,
toggleJellyfinDiscovery: () => {},
openAnilistSetupWindow: () => {},
checkForUpdates: () => {},
quitApp: () => {},
},
ensureTrayDeps: {
+44 -4
View File
@@ -31,6 +31,7 @@ test('tray menu template contains expected entries and handlers', () => {
const template = buildTrayMenuTemplateRuntime({
openSessionHelp: () => calls.push('help'),
openTexthookerInBrowser: () => calls.push('texthooker'),
showTexthookerPage: true,
openFirstRunSetup: () => calls.push('setup'),
showFirstRunSetup: true,
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
@@ -42,10 +43,11 @@ test('tray menu template contains expected entries and handlers', () => {
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => calls.push('jellyfin-discovery'),
openAnilistSetup: () => calls.push('anilist'),
checkForUpdates: () => calls.push('updates'),
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 11);
assert.equal(template.length, 12);
assert.equal(
template.some((entry) => entry.label === 'Open Overlay'),
false,
@@ -58,15 +60,25 @@ test('tray menu template contains expected entries and handlers', () => {
template[0]!.click?.();
assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.();
template[9]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[10]!.click?.();
assert.deepEqual(calls, ['jellyfin-discovery', 'help', 'texthooker', 'separator', 'quit']);
assert.equal(template[9]!.label, 'Check for Updates');
template[9]!.click?.();
template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[11]!.click?.();
assert.deepEqual(calls, [
'jellyfin-discovery',
'help',
'texthooker',
'updates',
'separator',
'quit',
]);
});
test('tray menu template omits first-run setup entry when setup is complete', () => {
const labels = buildTrayMenuTemplateRuntime({
openSessionHelp: () => undefined,
openTexthookerInBrowser: () => undefined,
showTexthookerPage: true,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
@@ -78,6 +90,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => undefined,
openAnilistSetup: () => undefined,
checkForUpdates: () => undefined,
quitApp: () => undefined,
})
.map((entry) => entry.label)
@@ -88,10 +101,36 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
assert.equal(labels.includes('Jellyfin Discovery'), false);
});
test('tray menu template omits texthooker entry when texthooker page is disabled', () => {
const labels = buildTrayMenuTemplateRuntime({
openSessionHelp: () => undefined,
openTexthookerInBrowser: () => undefined,
showTexthookerPage: false,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openRuntimeOptions: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
jellyfinDiscoveryActive: false,
toggleJellyfinDiscovery: () => undefined,
openAnilistSetup: () => undefined,
checkForUpdates: () => undefined,
quitApp: () => undefined,
})
.map((entry) => entry.label)
.filter(Boolean);
assert.equal(labels.includes('Open Texthooker'), false);
});
test('tray menu template renders active jellyfin discovery checkbox', () => {
const template = buildTrayMenuTemplateRuntime({
openSessionHelp: () => undefined,
openTexthookerInBrowser: () => undefined,
showTexthookerPage: true,
openFirstRunSetup: () => undefined,
showFirstRunSetup: false,
openWindowsMpvLauncherSetup: () => undefined,
@@ -103,6 +142,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
jellyfinDiscoveryActive: true,
toggleJellyfinDiscovery: () => undefined,
openAnilistSetup: () => undefined,
checkForUpdates: () => undefined,
quitApp: () => undefined,
});
+14 -4
View File
@@ -32,6 +32,7 @@ export function resolveTrayIconPathRuntime(deps: {
export type TrayMenuActionHandlers = {
openSessionHelp: () => void;
openTexthookerInBrowser: () => void;
showTexthookerPage: boolean;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
@@ -43,6 +44,7 @@ export type TrayMenuActionHandlers = {
jellyfinDiscoveryActive: boolean;
toggleJellyfinDiscovery: () => void;
openAnilistSetup: () => void;
checkForUpdates: () => void;
quitApp: () => void;
};
@@ -58,10 +60,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Open Help',
click: handlers.openSessionHelp,
},
{
label: 'Open Texthooker',
click: handlers.openTexthookerInBrowser,
},
...(handlers.showTexthookerPage
? [
{
label: 'Open Texthooker',
click: handlers.openTexthookerInBrowser,
},
]
: []),
...(handlers.showFirstRunSetup
? [
{
@@ -105,6 +111,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Configure AniList',
click: handlers.openAnilistSetup,
},
{
label: 'Check for Updates',
click: handlers.checkForUpdates,
},
{ type: 'separator' },
{
label: 'Quit',
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { configureAutoUpdater, type ElectronAutoUpdaterLike } from './app-updater';
type UpdaterLogger = {
info: (message: string) => void;
debug: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};
test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => {
const logged: string[] = [];
const updater: ElectronAutoUpdaterLike & { logger?: UpdaterLogger | null } = {
autoDownload: true,
allowPrerelease: true,
allowDowngrade: true,
logger: null,
checkForUpdates: async () => null,
downloadUpdate: async () => [],
quitAndInstall: () => {},
};
configureAutoUpdater(updater, (message) => logged.push(message));
assert.equal(updater.autoDownload, false);
assert.equal(updater.allowPrerelease, false);
assert.equal(updater.allowDowngrade, false);
assert.ok(updater.logger);
updater.logger.info('Checking for update');
updater.logger.debug('Generated new staging user ID');
updater.logger.warn('metadata missing');
updater.logger.error('download failed');
assert.deepEqual(logged, ['metadata missing', 'download failed']);
});
test('configureAutoUpdater allows prereleases only for the prerelease channel', () => {
const updater: ElectronAutoUpdaterLike = {
autoDownload: true,
allowPrerelease: false,
allowDowngrade: true,
logger: null,
checkForUpdates: async () => null,
downloadUpdate: async () => [],
quitAndInstall: () => {},
};
configureAutoUpdater(updater, () => {}, 'prerelease');
assert.equal(updater.allowPrerelease, true);
configureAutoUpdater(updater, () => {}, 'stable');
assert.equal(updater.allowPrerelease, false);
});
+92
View File
@@ -0,0 +1,92 @@
import { autoUpdater as electronAutoUpdater } from 'electron-updater';
import type { UpdateChannel } from '../../../types/config';
import { compareSemverLike } from './release-assets';
export interface AppUpdateCheckResult {
available: boolean;
version: string;
canUpdate: boolean;
}
export interface ElectronUpdaterLoggerLike {
info?: (message: string, ...args: unknown[]) => void;
debug?: (message: string, ...args: unknown[]) => void;
warn?: (message: string, ...args: unknown[]) => void;
error?: (message: string, ...args: unknown[]) => void;
}
export interface ElectronAutoUpdaterLike {
autoDownload: boolean;
allowPrerelease: boolean;
allowDowngrade: boolean;
logger?: ElectronUpdaterLoggerLike | null;
checkForUpdates: () => Promise<{
updateInfo?: {
version?: string;
};
} | null>;
downloadUpdate: () => Promise<unknown>;
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
}
export function configureAutoUpdater(
updater: ElectronAutoUpdaterLike,
log: (message: string) => void = () => {},
channel: UpdateChannel = 'stable',
): ElectronAutoUpdaterLike {
updater.autoDownload = false;
updater.allowPrerelease = channel === 'prerelease';
updater.allowDowngrade = false;
updater.logger = {
info: () => {},
debug: () => {},
warn: (message) => log(message),
error: (message) => log(message),
};
return updater;
}
export function createElectronAppUpdater(options: {
currentVersion: string;
isPackaged: boolean;
updater?: ElectronAutoUpdaterLike;
log: (message: string) => void;
getChannel?: () => UpdateChannel;
}) {
const getChannel = options.getChannel ?? (() => 'stable' as const);
const updater = configureAutoUpdater(
options.updater ?? electronAutoUpdater,
options.log,
getChannel(),
);
return {
async checkForUpdates(channel?: UpdateChannel): Promise<AppUpdateCheckResult> {
if (!options.isPackaged) {
return {
available: false,
version: options.currentVersion,
canUpdate: false,
};
}
configureAutoUpdater(updater, options.log, channel ?? getChannel());
const result = await updater.checkForUpdates();
const version = result?.updateInfo?.version ?? options.currentVersion;
return {
available: compareSemverLike(version, options.currentVersion) > 0,
version,
canUpdate: true,
};
},
async downloadUpdate(): Promise<void> {
if (!options.isPackaged) {
options.log('Skipping app update download because this build is not packaged.');
return;
}
await updater.downloadUpdate();
},
quitAndInstall(): void {
updater.quitAndInstall(false, true);
},
};
}
@@ -0,0 +1,126 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import {
buildProtectedLauncherUpdateCommand,
looksLikeSubminerLauncher,
updateLauncherAtPath,
} from './launcher-updater';
const launcherBytes = Buffer.from('#!/usr/bin/env bash\n# SubMiner launcher\nexec SubMiner "$@"\n');
const launcherHash = createHash('sha256').update(launcherBytes).digest('hex');
test('looksLikeSubminerLauncher rejects unrelated executable content', () => {
assert.equal(looksLikeSubminerLauncher(Buffer.from('#!/bin/sh\necho nope\n')), false);
assert.equal(looksLikeSubminerLauncher(Buffer.from('SubMiner launcher binary payload')), true);
});
test('buildProtectedLauncherUpdateCommand uses sudo curl and chmod for protected paths', () => {
assert.equal(
buildProtectedLauncherUpdateCommand(
'https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer',
'/usr/local/bin/subminer',
),
'sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer && sudo chmod +x /usr/local/bin/subminer',
);
});
test('updateLauncherAtPath verifies hash and atomically replaces writable launcher', async () => {
const writes: Array<{ path: string; data: Buffer }> = [];
const renames: Array<{ from: string; to: string }> = [];
const chmods: Array<{ path: string; mode: number }> = [];
const result = await updateLauncherAtPath({
launcherPath: '/home/kyle/.local/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: launcherHash,
download: async () => launcherBytes,
fs: {
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
stat: async () => ({ isFile: () => true, mode: 0o755 }),
access: async () => undefined,
writeFile: async (filePath, data) => {
writes.push({ path: filePath, data: Buffer.from(data) });
},
chmod: async (filePath, mode) => {
chmods.push({ path: filePath, mode });
},
rename: async (from, to) => {
renames.push({ from, to });
},
unlink: async () => undefined,
},
});
assert.equal(result.status, 'updated');
assert.equal(writes.length, 1);
assert.equal(writes[0]!.path, '/home/kyle/.local/bin/.subminer.update');
assert.equal(writes[0]!.data.equals(launcherBytes), true);
assert.deepEqual(chmods, [{ path: '/home/kyle/.local/bin/.subminer.update', mode: 0o755 }]);
assert.deepEqual(renames, [
{ from: '/home/kyle/.local/bin/.subminer.update', to: '/home/kyle/.local/bin/subminer' },
]);
});
test('updateLauncherAtPath reports protected command without replacing non-writable launcher', async () => {
const result = await updateLauncherAtPath({
launcherPath: '/usr/local/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: launcherHash,
download: async () => launcherBytes,
fs: {
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
stat: async () => ({ isFile: () => true, mode: 0o755 }),
access: async () => {
throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
},
writeFile: async () => {
throw new Error('unexpected write');
},
chmod: async () => undefined,
rename: async () => undefined,
unlink: async () => undefined,
},
});
assert.equal(result.status, 'protected');
assert.match(result.command ?? '', /^sudo curl -fSL https:\/\/example\.test\/subminer/);
});
test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher content', async () => {
const suspicious = await updateLauncherAtPath({
launcherPath: '/home/kyle/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: launcherHash,
download: async () => launcherBytes,
fs: {
readFile: async () => Buffer.from('#!/bin/sh\necho not-subminer\n'),
stat: async () => ({ isFile: () => true, mode: 0o755 }),
access: async () => undefined,
writeFile: async () => undefined,
chmod: async () => undefined,
rename: async () => undefined,
unlink: async () => undefined,
},
});
const mismatch = await updateLauncherAtPath({
launcherPath: '/home/kyle/.local/bin/subminer',
assetUrl: 'https://example.test/subminer',
expectedSha256: '0'.repeat(64),
download: async () => launcherBytes,
fs: {
readFile: async () => Buffer.from('#!/bin/sh\n# SubMiner launcher\n'),
stat: async () => ({ isFile: () => true, mode: 0o755 }),
access: async () => undefined,
writeFile: async () => {
throw new Error('unexpected write');
},
chmod: async () => undefined,
rename: async () => undefined,
unlink: async () => undefined,
},
});
assert.equal(suspicious.status, 'skipped');
assert.equal(mismatch.status, 'hash-mismatch');
});
+184
View File
@@ -0,0 +1,184 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { GitHubRelease } from './release-assets';
import { findReleaseAsset } from './release-assets';
type StatLike = {
isFile: () => boolean;
mode?: number;
};
export type LauncherUpdateStatus =
| 'updated'
| 'skipped'
| 'protected'
| 'hash-mismatch'
| 'not-found'
| 'missing-asset';
export interface LauncherUpdateResult {
status: LauncherUpdateStatus;
path?: string;
command?: string;
message?: string;
}
export interface LauncherUpdateFileSystem {
readFile: (targetPath: string) => Promise<Buffer | string>;
stat: (targetPath: string) => Promise<StatLike>;
access: (targetPath: string) => Promise<void>;
writeFile: (targetPath: string, data: Buffer) => Promise<void>;
chmod: (targetPath: string, mode: number) => Promise<void>;
rename: (fromPath: string, toPath: string) => Promise<void>;
unlink: (targetPath: string) => Promise<void>;
}
export function looksLikeSubminerLauncher(content: Buffer | string): boolean {
const text = Buffer.isBuffer(content) ? content.toString('utf8') : content;
return (
text.includes('SubMiner launcher') ||
text.includes('Launch MPV with SubMiner') ||
text.includes('SUBMINER_APPIMAGE_PATH') ||
text.includes('SubMiner.app') ||
text.includes('SubMiner.AppImage')
);
}
export function buildProtectedLauncherUpdateCommand(
assetUrl: string,
launcherPath: string,
): string {
return `sudo curl -fSL ${assetUrl} -o ${launcherPath} && sudo chmod +x ${launcherPath}`;
}
function sha256(data: Buffer): string {
return createHash('sha256').update(data).digest('hex');
}
function defaultFs(): LauncherUpdateFileSystem {
return {
readFile: (targetPath) => fs.promises.readFile(targetPath),
stat: (targetPath) => fs.promises.stat(targetPath),
access: async (targetPath) => {
await fs.promises.access(targetPath, fs.constants.W_OK);
},
writeFile: (targetPath, data) => fs.promises.writeFile(targetPath, data),
chmod: (targetPath, mode) => fs.promises.chmod(targetPath, mode),
rename: (fromPath, toPath) => fs.promises.rename(fromPath, toPath),
unlink: async (targetPath) => {
await fs.promises.unlink(targetPath).catch(() => undefined);
},
};
}
export async function updateLauncherAtPath(options: {
launcherPath: string;
assetUrl: string;
expectedSha256: string;
download: () => Promise<Buffer>;
fs?: LauncherUpdateFileSystem;
}): Promise<LauncherUpdateResult> {
const fsDeps = options.fs ?? defaultFs();
let stat: StatLike;
try {
stat = await fsDeps.stat(options.launcherPath);
} catch {
return { status: 'not-found', path: options.launcherPath };
}
if (!stat.isFile()) {
return { status: 'skipped', path: options.launcherPath, message: 'Launcher is not a file.' };
}
const existing = await fsDeps.readFile(options.launcherPath);
if (!looksLikeSubminerLauncher(existing)) {
return {
status: 'skipped',
path: options.launcherPath,
message: 'Existing executable does not look like a SubMiner launcher.',
};
}
try {
await fsDeps.access(options.launcherPath);
} catch {
return {
status: 'protected',
path: options.launcherPath,
command: buildProtectedLauncherUpdateCommand(options.assetUrl, options.launcherPath),
};
}
const data = await options.download();
const actualSha256 = sha256(data);
if (actualSha256 !== options.expectedSha256.toLowerCase()) {
return {
status: 'hash-mismatch',
path: options.launcherPath,
message: `Expected ${options.expectedSha256}, got ${actualSha256}.`,
};
}
const tempPath = path.join(path.dirname(options.launcherPath), '.subminer.update');
try {
await fsDeps.writeFile(tempPath, data);
await fsDeps.chmod(tempPath, stat.mode ? stat.mode & 0o777 : 0o755);
await fsDeps.rename(tempPath, options.launcherPath);
return { status: 'updated', path: options.launcherPath };
} catch (error) {
await fsDeps.unlink(tempPath);
throw error;
}
}
export function detectLauncherCandidates(options: {
platform: NodeJS.Platform;
homeDir: string;
explicitPath?: string;
}): string[] {
const candidates: string[] = [];
if (options.explicitPath) candidates.push(options.explicitPath);
if (options.platform === 'darwin') {
candidates.push('/usr/local/bin/subminer', '/opt/homebrew/bin/subminer');
candidates.push(path.join(options.homeDir, '.local/bin/subminer'));
} else if (options.platform === 'linux') {
candidates.push(path.join(options.homeDir, '.local/bin/subminer'));
candidates.push('/usr/local/bin/subminer', '/usr/bin/subminer');
}
return [...new Set(candidates)];
}
export async function updateLauncherFromRelease(options: {
release: GitHubRelease | null;
sha256Sums: Map<string, string>;
launcherPath?: string;
platform?: NodeJS.Platform;
homeDir?: string;
downloadAsset: (url: string) => Promise<Buffer>;
exists?: (targetPath: string) => boolean;
}): Promise<LauncherUpdateResult> {
if (!options.release) return { status: 'missing-asset', message: 'No release found.' };
const asset = findReleaseAsset(options.release, 'subminer');
if (!asset) return { status: 'missing-asset', message: 'Release has no subminer asset.' };
const expectedSha256 = options.sha256Sums.get('subminer');
if (!expectedSha256) {
return { status: 'missing-asset', message: 'SHA256SUMS.txt has no subminer entry.' };
}
const exists = options.exists ?? fs.existsSync;
const candidates = detectLauncherCandidates({
platform: options.platform ?? process.platform,
homeDir: options.homeDir ?? os.homedir(),
explicitPath: options.launcherPath,
});
const targetPath = candidates.find((candidate) => exists(candidate));
if (!targetPath) return { status: 'not-found', message: 'No installed launcher detected.' };
return await updateLauncherAtPath({
launcherPath: targetPath,
assetUrl: asset.browser_download_url,
expectedSha256,
download: () => options.downloadAsset(asset.browser_download_url),
});
}
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
compareSemverLike,
findReleaseAsset,
parseSha256Sums,
selectLatestStableRelease,
} from './release-assets';
test('parseSha256Sums maps release asset basenames to hashes', () => {
const sums = parseSha256Sums(`
1111111111111111111111111111111111111111111111111111111111111111 SubMiner.AppImage
2222222222222222222222222222222222222222222222222222222222222222 *subminer
`);
assert.equal(
sums.get('SubMiner.AppImage'),
'1111111111111111111111111111111111111111111111111111111111111111',
);
assert.equal(
sums.get('subminer'),
'2222222222222222222222222222222222222222222222222222222222222222',
);
});
test('selectLatestStableRelease ignores drafts and prereleases', () => {
const release = selectLatestStableRelease([
{ tag_name: 'v0.16.0-beta.1', draft: false, prerelease: true, assets: [] },
{ tag_name: 'v0.15.0', draft: true, prerelease: false, assets: [] },
{ tag_name: 'v0.14.1', draft: false, prerelease: false, assets: [] },
]);
assert.equal(release?.tag_name, 'v0.14.1');
});
test('selectLatestStableRelease can opt into prerelease releases', () => {
const release = selectLatestStableRelease(
[
{ tag_name: 'v0.16.0-beta.1', draft: false, prerelease: true, assets: [] },
{ tag_name: 'v0.15.0', draft: false, prerelease: false, assets: [] },
],
'prerelease',
);
assert.equal(release?.tag_name, 'v0.16.0-beta.1');
});
test('compareSemverLike orders prerelease identifiers within the same base version', () => {
assert.equal(compareSemverLike('0.15.0-beta.2', '0.15.0-beta.1') > 0, true);
assert.equal(compareSemverLike('0.15.0-rc.1', '0.15.0-beta.2') > 0, true);
assert.equal(compareSemverLike('0.15.0', '0.15.0-rc.1') > 0, true);
});
test('findReleaseAsset finds exact asset names only', () => {
const release = {
tag_name: 'v0.14.1',
draft: false,
prerelease: false,
assets: [
{ name: 'subminer', browser_download_url: 'https://example.test/subminer' },
{ name: 'subminer-assets.tar.gz', browser_download_url: 'https://example.test/assets' },
],
};
assert.equal(
findReleaseAsset(release, 'subminer')?.browser_download_url,
'https://example.test/subminer',
);
assert.equal(findReleaseAsset(release, 'latest.yml'), null);
});
+186
View File
@@ -0,0 +1,186 @@
import type { UpdateChannel } from '../../../types/config';
export interface GitHubReleaseAsset {
name: string;
browser_download_url: string;
size?: number;
}
export interface GitHubRelease {
tag_name: string;
name?: string;
draft?: boolean;
prerelease?: boolean;
html_url?: string;
assets: GitHubReleaseAsset[];
}
export interface FetchResponseLike {
ok: boolean;
status: number;
statusText?: string;
json: () => Promise<unknown>;
text: () => Promise<string>;
arrayBuffer: () => Promise<ArrayBuffer>;
}
export type FetchLike = (url: string, init?: Record<string, unknown>) => Promise<FetchResponseLike>;
export function parseSha256Sums(text: string): Map<string, string> {
const sums = new Map<string, string>();
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
if (!match) continue;
const [, hash, name] = match;
if (!hash || !name) continue;
sums.set(name.trim().split(/[\\/]/).pop() ?? name.trim(), hash.toLowerCase());
}
return sums;
}
export function selectLatestStableRelease(
releases: GitHubRelease[],
channel: UpdateChannel = 'stable',
): GitHubRelease | null {
return (
releases.find(
(release) => !release.draft && (channel === 'prerelease' || !release.prerelease),
) ?? null
);
}
export function findReleaseAsset(
release: Pick<GitHubRelease, 'assets'>,
assetName: string,
): GitHubReleaseAsset | null {
return release.assets.find((asset) => asset.name === assetName) ?? null;
}
function assertRelease(value: unknown): GitHubRelease | null {
if (!value || typeof value !== 'object') return null;
const release = value as Partial<GitHubRelease>;
if (typeof release.tag_name !== 'string' || !Array.isArray(release.assets)) return null;
return {
tag_name: release.tag_name,
name: typeof release.name === 'string' ? release.name : undefined,
draft: release.draft === true,
prerelease: release.prerelease === true,
html_url: typeof release.html_url === 'string' ? release.html_url : undefined,
assets: release.assets
.filter((asset): asset is GitHubReleaseAsset => {
const candidate = asset as Partial<GitHubReleaseAsset>;
return (
typeof candidate.name === 'string' && typeof candidate.browser_download_url === 'string'
);
})
.map((asset) => ({
name: asset.name,
browser_download_url: asset.browser_download_url,
size: typeof asset.size === 'number' ? asset.size : undefined,
})),
};
}
export async function fetchLatestStableRelease(options: {
fetch: FetchLike;
owner?: string;
repo?: string;
channel?: UpdateChannel;
}): Promise<GitHubRelease | null> {
const owner = options.owner ?? 'ksyasuda';
const repo = options.repo ?? 'SubMiner';
const response = await options.fetch(`https://api.github.com/repos/${owner}/${repo}/releases`, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'SubMiner updater',
},
});
if (!response.ok) {
throw new Error(`GitHub releases request failed with ${response.status}`);
}
const body = await response.json();
if (!Array.isArray(body)) return null;
return selectLatestStableRelease(
body.map(assertRelease).filter((item): item is GitHubRelease => item !== null),
options.channel,
);
}
export async function fetchReleaseAssetText(fetch: FetchLike, assetUrl: string): Promise<string> {
const response = await fetch(assetUrl);
if (!response.ok) {
throw new Error(`Release asset request failed with ${response.status}`);
}
return await response.text();
}
export async function fetchReleaseAssetBuffer(fetch: FetchLike, assetUrl: string): Promise<Buffer> {
const response = await fetch(assetUrl);
if (!response.ok) {
throw new Error(`Release asset request failed with ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
export function parseReleaseVersion(
release: Pick<GitHubRelease, 'tag_name'> | null,
): string | null {
if (!release) return null;
return release.tag_name.replace(/^v/i, '');
}
export function compareSemverLike(a: string, b: string): number {
const parse = (
value: string,
): {
core: number[];
prerelease: Array<number | string>;
} => {
const normalized = value.replace(/^v/i, '');
const [coreText = '', ...prereleaseParts] = normalized.split('-');
const core = coreText
.split('.')
.slice(0, 3)
.map((part) => Number.parseInt(part, 10) || 0);
while (core.length < 3) core.push(0);
const prereleaseText = prereleaseParts.join('-');
return {
core,
prerelease: prereleaseText
? prereleaseText.split('.').map((part) => {
const numeric = Number.parseInt(part, 10);
return /^\d+$/.test(part) ? numeric : part;
})
: [],
};
};
const left = parse(a);
const right = parse(b);
for (let i = 0; i < 3; i += 1) {
const diff = (left.core[i] ?? 0) - (right.core[i] ?? 0);
if (diff !== 0) return diff;
}
if (left.prerelease.length === 0 && right.prerelease.length === 0) return 0;
if (left.prerelease.length === 0) return 1;
if (right.prerelease.length === 0) return -1;
const length = Math.max(left.prerelease.length, right.prerelease.length);
for (let i = 0; i < length; i += 1) {
const leftPart = left.prerelease[i];
const rightPart = right.prerelease[i];
if (leftPart === undefined && rightPart === undefined) return 0;
if (leftPart === undefined) return -1;
if (rightPart === undefined) return 1;
if (leftPart === rightPart) continue;
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
return leftPart - rightPart;
}
if (typeof leftPart === 'number') return -1;
if (typeof rightPart === 'number') return 1;
return leftPart > rightPart ? 1 : -1;
}
return 0;
}
+164
View File
@@ -0,0 +1,164 @@
import { createHash } from 'node:crypto';
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import type { GitHubRelease } from './release-assets';
import { findReleaseAsset } from './release-assets';
const execFileAsync = promisify(execFile);
export interface SupportAssetsUpdateResult {
status: 'updated' | 'skipped' | 'protected' | 'hash-mismatch' | 'missing-asset';
path?: string;
command?: string;
message?: string;
}
function sha256(data: Buffer): string {
return createHash('sha256').update(data).digest('hex');
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function detectSupportAssetDataDirs(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgDataHome?: string;
}): string[] {
if (options.platform === 'darwin') {
return [
path.join(options.homeDir, 'Library/Application Support/SubMiner'),
'/usr/local/share/SubMiner',
];
}
if (options.platform === 'linux') {
const xdgDataHome = options.xdgDataHome || path.join(options.homeDir, '.local/share');
return [path.join(xdgDataHome, 'SubMiner'), '/usr/local/share/SubMiner', '/usr/share/SubMiner'];
}
return [];
}
export function buildProtectedSupportAssetsCommand(assetUrl: string, dataDir: string): string {
const quotedDir = shellQuote(dataDir);
return [
'tmp=$(mktemp -d)',
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
`sudo mkdir -p ${quotedDir}/plugin/subminer ${quotedDir}/themes`,
`sudo cp -R "$tmp/plugin/subminer/." ${quotedDir}/plugin/subminer/`,
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
].join(' && ');
}
async function pathExists(targetPath: string): Promise<boolean> {
return await fs.promises
.access(targetPath)
.then(() => true)
.catch(() => false);
}
async function canWrite(targetPath: string): Promise<boolean> {
return await fs.promises
.access(targetPath, fs.constants.W_OK)
.then(() => true)
.catch(() => false);
}
export async function updateSupportAssetsFromRelease(options: {
release: GitHubRelease | null;
sha256Sums: Map<string, string>;
downloadAsset: (url: string) => Promise<Buffer>;
platform?: NodeJS.Platform;
homeDir?: string;
xdgDataHome?: string;
}): Promise<SupportAssetsUpdateResult[]> {
if (!options.release) return [{ status: 'missing-asset', message: 'No release found.' }];
const asset = findReleaseAsset(options.release, 'subminer-assets.tar.gz');
if (!asset) return [{ status: 'missing-asset', message: 'Release has no support assets.' }];
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
if (!expectedSha256) {
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no support assets entry.' }];
}
const dataDirs = detectSupportAssetDataDirs({
platform: options.platform ?? process.platform,
homeDir: options.homeDir ?? os.homedir(),
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
});
const existingDataDirs: string[] = [];
for (const dataDir of dataDirs) {
const hasPlugin = await pathExists(path.join(dataDir, 'plugin/subminer'));
const hasTheme = await pathExists(path.join(dataDir, 'themes/subminer.rasi'));
if (hasPlugin || hasTheme) existingDataDirs.push(dataDir);
}
if (existingDataDirs.length === 0) {
return [{ status: 'skipped', message: 'No existing support asset install detected.' }];
}
const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs
.filter((dataDir) => !fs.existsSync(dataDir) || !fs.statSync(dataDir).isDirectory())
.map((dataDir) => ({
status: 'skipped' as const,
path: dataDir,
message: 'Support asset path is not a directory.',
}));
const writableDataDirs: string[] = [];
for (const dataDir of existingDataDirs) {
if (await canWrite(dataDir)) {
writableDataDirs.push(dataDir);
} else {
protectedResults.push({
status: 'protected',
path: dataDir,
command: buildProtectedSupportAssetsCommand(asset.browser_download_url, dataDir),
});
}
}
if (writableDataDirs.length === 0) return protectedResults;
const archive = await options.downloadAsset(asset.browser_download_url);
const actualSha256 = sha256(archive);
if (actualSha256 !== expectedSha256.toLowerCase()) {
return [
...protectedResults,
{
status: 'hash-mismatch',
message: `Expected ${expectedSha256}, got ${actualSha256}.`,
},
];
}
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-assets-'));
try {
const archivePath = path.join(tempDir, 'subminer-assets.tar.gz');
await fs.promises.writeFile(archivePath, archive);
await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir]);
const results: SupportAssetsUpdateResult[] = [...protectedResults];
for (const dataDir of writableDataDirs) {
const targetPluginDir = path.join(dataDir, 'plugin/subminer');
const targetThemePath = path.join(dataDir, 'themes/subminer.rasi');
if (await pathExists(targetPluginDir)) {
await fs.promises.mkdir(targetPluginDir, { recursive: true });
await fs.promises.cp(path.join(tempDir, 'plugin/subminer'), targetPluginDir, {
recursive: true,
force: true,
});
}
if (await pathExists(targetThemePath)) {
await fs.promises.mkdir(path.dirname(targetThemePath), { recursive: true });
await fs.promises.copyFile(
path.join(tempDir, 'assets/themes/subminer.rasi'),
targetThemePath,
);
}
results.push({ status: 'updated', path: dataDir });
}
return results;
} finally {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { CliArgs } from '../../../cli/args';
import { runUpdateCliCommand } from './update-cli-command';
test('runUpdateCliCommand writes launcher response for second-instance update handoff', async () => {
const writes: Array<{ path: string; payload: unknown }> = [];
await runUpdateCliCommand(
{
update: true,
updateLauncherPath: '/home/kyle/.local/bin/subminer',
updateResponsePath: '/tmp/subminer-update-response.json',
} as CliArgs,
'second-instance',
{
checkForUpdates: async (request) => {
assert.deepEqual(request, {
source: 'launcher',
launcherPath: '/home/kyle/.local/bin/subminer',
});
return { status: 'up-to-date', version: '0.15.0' };
},
writeResponse: (responsePath, payload) => {
writes.push({ path: responsePath, payload });
},
logWarn: () => {},
},
);
assert.deepEqual(writes, [
{
path: '/tmp/subminer-update-response.json',
payload: { ok: true, status: 'up-to-date', version: '0.15.0' },
},
]);
});
@@ -0,0 +1,71 @@
import fs from 'node:fs';
import path from 'node:path';
import type { CliArgs, CliCommandSource } from '../../../cli/args';
import type { UpdateCheckRequest, UpdateCheckResult } from './update-service';
export interface UpdateCliCommandResponse {
ok: boolean;
status?: UpdateCheckResult['status'];
version?: string;
error?: string;
}
export interface UpdateCliCommandDeps {
checkForUpdates: (request: UpdateCheckRequest) => Promise<UpdateCheckResult>;
writeResponse: (responsePath: string, payload: UpdateCliCommandResponse) => void;
logWarn: (message: string, error?: unknown) => void;
}
export function writeUpdateCliCommandResponse(
responsePath: string,
payload: UpdateCliCommandResponse,
): void {
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
fs.writeFileSync(responsePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
function responseFromResult(result: UpdateCheckResult): UpdateCliCommandResponse {
const response: UpdateCliCommandResponse = {
ok: result.status !== 'failed',
status: result.status,
};
if (result.version !== undefined) response.version = result.version;
if (result.error !== undefined) response.error = result.error;
return response;
}
function writeResponseSafe(
responsePath: string | undefined,
payload: UpdateCliCommandResponse,
deps: Pick<UpdateCliCommandDeps, 'writeResponse' | 'logWarn'>,
): void {
if (!responsePath) return;
try {
deps.writeResponse(responsePath, payload);
} catch (error) {
deps.logWarn(`Failed to write update response: ${responsePath}`, error);
}
}
export async function runUpdateCliCommand(
args: Pick<CliArgs, 'updateLauncherPath' | 'updateResponsePath'>,
_source: CliCommandSource,
deps: UpdateCliCommandDeps,
): Promise<UpdateCheckResult> {
try {
const result = await deps.checkForUpdates({
source: args.updateLauncherPath ? 'launcher' : 'manual',
launcherPath: args.updateLauncherPath,
});
writeResponseSafe(args.updateResponsePath, responseFromResult(result), deps);
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
writeResponseSafe(
args.updateResponsePath,
{ ok: false, status: 'failed', error: message },
deps,
);
throw error;
}
}
+68
View File
@@ -0,0 +1,68 @@
export type UpdateAvailableChoice = 'update' | 'close';
export type RestartChoice = 'restart' | 'later';
export interface MessageBoxResultLike {
response: number;
}
export type ShowMessageBox = (options: {
type?: 'info' | 'warning' | 'error' | 'question';
title?: string;
message: string;
detail?: string;
buttons?: string[];
defaultId?: number;
cancelId?: number;
}) => Promise<MessageBoxResultLike>;
export async function showNoUpdateDialog(
showMessageBox: ShowMessageBox,
version: string,
): Promise<void> {
await showMessageBox({
type: 'info',
title: 'SubMiner Updates',
message: `SubMiner is up to date (v${version})`,
buttons: ['Close'],
});
}
export async function showUpdateAvailableDialog(
showMessageBox: ShowMessageBox,
version: string,
): Promise<UpdateAvailableChoice> {
const result = await showMessageBox({
type: 'question',
title: 'SubMiner Updates',
message: `SubMiner v${version} is available`,
buttons: ['Update', 'Close'],
defaultId: 0,
cancelId: 1,
});
return result.response === 0 ? 'update' : 'close';
}
export async function showRestartDialog(showMessageBox: ShowMessageBox): Promise<RestartChoice> {
const result = await showMessageBox({
type: 'question',
title: 'SubMiner Updates',
message: 'Restart to update',
buttons: ['Restart', 'Later'],
defaultId: 0,
cancelId: 1,
});
return result.response === 0 ? 'restart' : 'later';
}
export async function showUpdateFailedDialog(
showMessageBox: ShowMessageBox,
message: string,
): Promise<void> {
await showMessageBox({
type: 'error',
title: 'SubMiner Updates',
message: 'Update check failed',
detail: message,
buttons: ['Close'],
});
}
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { notifyUpdateAvailable } from './update-notifications';
test('notifyUpdateAvailable routes system and osd notifications from config', async () => {
const calls: string[] = [];
const deps = {
showSystemNotification: (title: string, body: string) => {
calls.push(`system:${title}:${body}`);
},
showOsdNotification: async (message: string) => {
calls.push(`osd:${message}`);
},
log: (message: string) => {
calls.push(`log:${message}`);
},
};
await notifyUpdateAvailable({ notificationType: 'system', version: '0.15.0' }, deps);
await notifyUpdateAvailable({ notificationType: 'both', version: '0.15.0' }, deps);
await notifyUpdateAvailable({ notificationType: 'none', version: '0.15.0' }, deps);
assert.deepEqual(calls, [
'system:SubMiner update available:SubMiner v0.15.0 is available',
'system:SubMiner update available:SubMiner v0.15.0 is available',
'osd:SubMiner v0.15.0 is available',
]);
});
test('notifyUpdateAvailable logs osd fallback when overlay notification fails', async () => {
const calls: string[] = [];
await notifyUpdateAvailable(
{ notificationType: 'osd', version: '0.15.0' },
{
showSystemNotification: () => {
calls.push('system');
},
showOsdNotification: async () => {
throw new Error('mpv disconnected');
},
log: (message) => {
calls.push(message);
},
},
);
assert.deepEqual(calls, ['Update OSD notification failed: mpv disconnected']);
});
@@ -0,0 +1,26 @@
import type { UpdateNotificationType } from '../../../types/config';
export interface UpdateNotificationDeps {
showSystemNotification: (title: string, body: string) => void;
showOsdNotification: (message: string) => void | Promise<void>;
log: (message: string) => void;
}
export async function notifyUpdateAvailable(
options: { notificationType: UpdateNotificationType; version: string },
deps: UpdateNotificationDeps,
): Promise<void> {
if (options.notificationType === 'none') return;
const message = `SubMiner v${options.version} is available`;
if (options.notificationType === 'system' || options.notificationType === 'both') {
deps.showSystemNotification('SubMiner update available', message);
}
if (options.notificationType === 'osd' || options.notificationType === 'both') {
try {
await deps.showOsdNotification(message);
} catch (error) {
deps.log(`Update OSD notification failed: ${(error as Error).message}`);
}
}
}
@@ -0,0 +1,183 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createUpdateService, type UpdateServiceDeps, type UpdateState } from './update-service';
function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
let state: UpdateState = {};
const calls: string[] = [];
const deps: UpdateServiceDeps = {
getConfig: () => ({
enabled: true,
checkIntervalHours: 24,
notificationType: 'system',
channel: 'stable',
}),
getCurrentVersion: () => '0.14.0',
now: () => 1_000_000,
readState: async () => state,
writeState: async (nextState) => {
state = nextState;
calls.push(`state:${JSON.stringify(nextState)}`);
},
checkAppUpdate: async () => ({ available: false, version: '0.14.0' }),
fetchLatestStableRelease: async () => ({
tag_name: 'v0.14.0',
prerelease: false,
draft: false,
assets: [],
}),
updateLauncher: async () => ({ status: 'skipped' }),
showNoUpdateDialog: async (version) => {
calls.push(`no-update:${version}`);
},
showUpdateAvailableDialog: async (version) => {
calls.push(`available-dialog:${version}`);
return 'close';
},
showUpdateFailedDialog: async (message) => {
calls.push(`failed:${message}`);
},
downloadAppUpdate: async () => {
calls.push('download');
},
showRestartDialog: async () => {
calls.push('restart-dialog');
return 'later';
},
quitAndInstall: () => calls.push('quit-install'),
notifyUpdateAvailable: async (version) => {
calls.push(`notify:${version}`);
},
log: (message) => calls.push(`log:${message}`),
...overrides,
};
return {
deps,
calls,
getState: () => state,
setState: (nextState: UpdateState) => (state = nextState),
};
}
test('manual update check shows latest-version dialog when already current', async () => {
const { deps, calls } = createDeps();
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'up-to-date');
assert.deepEqual(calls, ['no-update:0.14.0']);
});
test('manual update check falls back to GitHub release when app metadata is unavailable', async () => {
const { deps, calls } = createDeps({
checkAppUpdate: async () => {
throw new Error('latest-linux.yml missing');
},
fetchLatestStableRelease: async () => ({
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [],
}),
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'update-available');
assert.deepEqual(calls, ['available-dialog:0.15.0']);
});
test('automatic update check skips inside configured interval', async () => {
const { deps, calls, setState } = createDeps();
setState({ lastAutomaticCheckAt: 1_000_000 - 60 * 60 * 1000 });
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'automatic' });
assert.equal(result.status, 'skipped');
assert.deepEqual(calls, []);
});
test('automatic update check notifies once per version and records check time', async () => {
const { deps, calls, getState } = createDeps({
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
});
const service = createUpdateService(deps);
const first = await service.checkForUpdates({ source: 'automatic' });
const second = await service.checkForUpdates({ source: 'automatic', force: true });
assert.equal(first.status, 'update-available');
assert.equal(second.status, 'update-available');
assert.deepEqual(
calls.filter((call) => call === 'notify:0.15.0'),
['notify:0.15.0'],
);
assert.equal(getState().lastNotifiedVersion, '0.15.0');
assert.equal(getState().lastAutomaticCheckAt, 1_000_000);
});
test('concurrent update checks share one in-flight check', async () => {
let checkCount = 0;
let resolveCheck: (value: { available: boolean; version: string }) => void = () => {};
const { deps } = createDeps({
checkAppUpdate: () =>
new Promise((resolve) => {
checkCount += 1;
resolveCheck = resolve;
}),
});
const service = createUpdateService(deps);
const first = service.checkForUpdates({ source: 'manual' });
const second = service.checkForUpdates({ source: 'manual' });
await Promise.resolve();
resolveCheck({ available: false, version: '0.14.0' });
await Promise.all([first, second]);
assert.equal(checkCount, 1);
});
test('manual prerelease update check uses prerelease release and launcher channel', async () => {
const { deps, calls } = createDeps({
getConfig: () => ({
enabled: true,
checkIntervalHours: 24,
notificationType: 'system',
channel: 'prerelease',
}),
checkAppUpdate: async () => ({ available: true, version: '0.15.0-beta.1' }),
fetchLatestStableRelease: async (channel) => {
calls.push(`fetch:${channel}`);
return {
tag_name: 'v0.15.0-beta.1',
prerelease: true,
draft: false,
assets: [],
};
},
showUpdateAvailableDialog: async (version) => {
calls.push(`available-dialog:${version}`);
return 'update';
},
updateLauncher: async (_launcherPath, channel) => {
calls.push(`launcher:${channel}`);
return { status: 'skipped' };
},
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({ source: 'manual' });
assert.equal(result.status, 'updated');
assert.deepEqual(calls, [
'fetch:prerelease',
'available-dialog:0.15.0-beta.1',
'download',
'launcher:prerelease',
'restart-dialog',
]);
});
+224
View File
@@ -0,0 +1,224 @@
import fs from 'node:fs';
import path from 'node:path';
import type { UpdateChannel, UpdatesConfig } from '../../../types/config';
import type { GitHubRelease } from './release-assets';
import { compareSemverLike, parseReleaseVersion } from './release-assets';
export interface UpdateState {
lastAutomaticCheckAt?: number;
lastNotifiedVersion?: string;
}
export type UpdateCheckSource = 'manual' | 'automatic' | 'launcher';
export interface UpdateCheckRequest {
source: UpdateCheckSource;
force?: boolean;
launcherPath?: string;
}
export type UpdateCheckStatus =
| 'up-to-date'
| 'update-available'
| 'updated'
| 'skipped'
| 'failed';
export interface UpdateCheckResult {
status: UpdateCheckStatus;
version?: string;
error?: string;
}
export interface UpdateServiceDeps {
getConfig: () => Required<UpdatesConfig>;
getCurrentVersion: () => string;
now: () => number;
readState: () => Promise<UpdateState>;
writeState: (state: UpdateState) => Promise<void>;
checkAppUpdate: (
channel: UpdateChannel,
) => Promise<{ available: boolean; version: string; canUpdate?: boolean }>;
fetchLatestStableRelease: (channel: UpdateChannel) => Promise<GitHubRelease | null>;
updateLauncher: (
launcherPath?: string,
channel?: UpdateChannel,
) => Promise<{ status: string; command?: string }>;
showNoUpdateDialog: (version: string) => Promise<void>;
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
showUpdateFailedDialog: (message: string) => Promise<void>;
downloadAppUpdate: () => Promise<void>;
showRestartDialog: () => Promise<'restart' | 'later'>;
quitAndInstall: () => void;
notifyUpdateAvailable: (version: string) => Promise<void>;
log: (message: string) => void;
setTimeout?: (callback: () => void, delayMs: number) => unknown;
setInterval?: (callback: () => void, delayMs: number) => unknown;
}
function getBestLatestVersion(
currentVersion: string,
appUpdate: { available: boolean; version: string },
release: GitHubRelease | null,
): { available: boolean; version: string } {
const releaseVersion = parseReleaseVersion(release);
const candidates = [appUpdate.version, releaseVersion].filter(
(value): value is string => typeof value === 'string' && value.length > 0,
);
const latest = candidates.reduce(
(best, candidate) => (compareSemverLike(candidate, best) > 0 ? candidate : best),
currentVersion,
);
return {
available: appUpdate.available || compareSemverLike(latest, currentVersion) > 0,
version: latest,
};
}
function shouldSkipAutomaticCheck(
config: Required<UpdatesConfig>,
state: UpdateState,
now: number,
) {
if (!config.enabled) return true;
if (!state.lastAutomaticCheckAt) return false;
const intervalMs = Math.max(1, config.checkIntervalHours) * 60 * 60 * 1000;
return now - state.lastAutomaticCheckAt < intervalMs;
}
function summarizeError(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error);
const firstLine = raw
.split('\n')
.map((line) => line.trim())
.find((line) => line.length > 0);
return firstLine ?? 'unknown error';
}
export function createUpdateService(deps: UpdateServiceDeps) {
let inFlight: Promise<UpdateCheckResult> | null = null;
async function runCheck(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
const now = deps.now();
const config = deps.getConfig();
const channel = config.channel;
const state = await deps.readState();
const isAutomatic = request.source === 'automatic';
if (isAutomatic && !request.force && shouldSkipAutomaticCheck(config, state, now)) {
return { status: 'skipped' };
}
try {
const [appUpdate, release] = await Promise.all([
deps.checkAppUpdate(channel).catch((error) => {
if (isAutomatic) {
deps.log(`App update metadata check failed: ${summarizeError(error)}`);
}
return {
available: false,
version: deps.getCurrentVersion(),
canUpdate: false,
};
}),
deps.fetchLatestStableRelease(channel).catch((error) => {
deps.log(`GitHub release update check failed: ${(error as Error).message}`);
return null;
}),
]);
const currentVersion = deps.getCurrentVersion();
const latest = getBestLatestVersion(currentVersion, appUpdate, release);
if (isAutomatic) {
const nextState: UpdateState = {
...state,
lastAutomaticCheckAt: now,
};
if (latest.available && state.lastNotifiedVersion !== latest.version) {
await deps.notifyUpdateAvailable(latest.version);
nextState.lastNotifiedVersion = latest.version;
}
await deps.writeState(nextState);
}
if (!latest.available) {
if (!isAutomatic) {
await deps.showNoUpdateDialog(currentVersion);
}
return { status: 'up-to-date', version: currentVersion };
}
if (isAutomatic) {
return { status: 'update-available', version: latest.version };
}
const choice = await deps.showUpdateAvailableDialog(latest.version);
if (choice === 'close') {
return { status: 'update-available', version: latest.version };
}
if (appUpdate.available && appUpdate.canUpdate !== false) {
await deps.downloadAppUpdate();
}
const launcherResult = await deps.updateLauncher(request.launcherPath, channel);
if (launcherResult.status === 'protected' && launcherResult.command) {
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
}
const restartChoice = await deps.showRestartDialog();
if (restartChoice === 'restart') {
deps.quitAndInstall();
}
return { status: 'updated', version: latest.version };
} catch (error) {
const message = (error as Error).message;
if (isAutomatic) {
deps.log(`Automatic update check failed: ${message}`);
} else {
await deps.showUpdateFailedDialog(message);
}
return { status: 'failed', error: message };
}
}
return {
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
if (inFlight) return inFlight;
inFlight = runCheck(request).finally(() => {
inFlight = null;
});
return inFlight;
},
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
const setTimeoutFn = deps.setTimeout ?? setTimeout;
const setIntervalFn = deps.setInterval ?? setInterval;
const startupDelayMs = options.startupDelayMs ?? 15_000;
const pollIntervalMs = options.pollIntervalMs ?? 60 * 60 * 1000;
setTimeoutFn(() => {
void this.checkForUpdates({ source: 'automatic' });
}, startupDelayMs);
setIntervalFn(() => {
void this.checkForUpdates({ source: 'automatic' });
}, pollIntervalMs);
},
};
}
export function createFileUpdateStateStore(statePath: string): {
readState: () => Promise<UpdateState>;
writeState: (state: UpdateState) => Promise<void>;
} {
return {
async readState(): Promise<UpdateState> {
try {
return JSON.parse(await fs.promises.readFile(statePath, 'utf8')) as UpdateState;
} catch {
return {};
}
},
async writeState(state: UpdateState): Promise<void> {
await fs.promises.mkdir(path.dirname(statePath), { recursive: true });
await fs.promises.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
},
};
}

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