mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-15 08:12:53 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb0dbfaad | |||
|
801cdcafca
|
|||
|
094bcce0dc
|
|||
|
d1ec678d7a
|
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed macOS overlay tracking so transient mpv window misses no longer hide the overlay; minimizing mpv still hides it.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed subtitle sync modal opens so macOS no longer flashes and hides the first modal attempt or leaves stale modal state after syncing.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: internal
|
||||
area: tests
|
||||
|
||||
- Removed stale Yomitan vendor source-inspection assertions for changes that were not shipped.
|
||||
@@ -0,0 +1,11 @@
|
||||
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.
|
||||
- Removed the default native app menu from Yomitan settings so File > Quit cannot put the tray app into a stuck quit state.
|
||||
- Disabled Yomitan's embedded popup preview in the tray-launched settings window to avoid renderer hangs during normal sidebar navigation.
|
||||
- Skipped heavy Yomitan settings startup preview, storage, dictionary, and Anki controllers when launched from SubMiner to avoid renderer hangs with large dictionary databases.
|
||||
- Cached Yomitan settings dictionary metadata after explicit loads to avoid repeated large IndexedDB reads.
|
||||
- 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
|
||||
const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com';
|
||||
const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js';
|
||||
const PLAUSIBLE_ENDPOINT = `${PLAUSIBLE_PROXY_HOSTNAME}/api/event`;
|
||||
const PLAUSIBLE_INIT_SCRIPT = [
|
||||
'window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};',
|
||||
`plausible.init({ endpoint: '${PLAUSIBLE_ENDPOINT}' });`,
|
||||
].join('\n');
|
||||
|
||||
function pageToCanonicalHref(page: string): string | null {
|
||||
if (page === '404.md') return null;
|
||||
@@ -15,6 +22,15 @@ export default {
|
||||
description:
|
||||
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
|
||||
head: [
|
||||
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
|
||||
[
|
||||
'script',
|
||||
{
|
||||
async: '',
|
||||
src: `${PLAUSIBLE_PROXY_HOSTNAME}${PLAUSIBLE_SITE_SCRIPT_PATH}`,
|
||||
},
|
||||
],
|
||||
['script', {}, PLAUSIBLE_INIT_SCRIPT],
|
||||
['link', { rel: 'icon', href: '/favicon.ico', sizes: 'any' }],
|
||||
[
|
||||
'link',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -3,25 +3,34 @@ import { readFileSync } from 'node:fs';
|
||||
|
||||
const docsConfigPath = new URL('./.vitepress/config.ts', import.meta.url);
|
||||
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
||||
const docsPackagePath = new URL('./package.json', import.meta.url);
|
||||
const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
|
||||
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
||||
const docsPackageContents = readFileSync(docsPackagePath, 'utf8');
|
||||
|
||||
test('docs site keeps docs hostname while sending plausible events to subminer.moe via worker.subminer.moe capture endpoint', () => {
|
||||
test('docs site loads the docs.subminer.moe Plausible script through the analytics proxy', () => {
|
||||
expect(docsConfigContents).toContain("const DOCS_HOSTNAME = 'https://docs.subminer.moe'");
|
||||
expect(docsConfigContents).toContain('hostname: DOCS_HOSTNAME');
|
||||
expect(docsThemeContents).toContain("const PLAUSIBLE_DOMAIN = 'subminer.moe'");
|
||||
expect(docsThemeContents).toContain('const PLAUSIBLE_ENABLED_HOSTNAMES = new Set([');
|
||||
expect(docsThemeContents).toContain("'docs.subminer.moe'");
|
||||
expect(docsThemeContents).toContain(
|
||||
"const PLAUSIBLE_ENDPOINT = 'https://worker.subminer.moe/api/capture'",
|
||||
expect(docsConfigContents).toContain(
|
||||
"const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com'",
|
||||
);
|
||||
expect(docsThemeContents).toContain('@plausible-analytics/tracker');
|
||||
expect(docsThemeContents).toContain('const { init } = await import');
|
||||
expect(docsThemeContents).toContain('!PLAUSIBLE_ENABLED_HOSTNAMES.has(window.location.hostname)');
|
||||
expect(docsThemeContents).toContain('domain: PLAUSIBLE_DOMAIN');
|
||||
expect(docsThemeContents).toContain('endpoint: PLAUSIBLE_ENDPOINT');
|
||||
expect(docsThemeContents).toContain('outboundLinks: true');
|
||||
expect(docsThemeContents).toContain('fileDownloads: true');
|
||||
expect(docsThemeContents).toContain('formSubmissions: true');
|
||||
expect(docsThemeContents).toContain('captureOnLocalhost: false');
|
||||
expect(docsConfigContents).toContain(
|
||||
"const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js'",
|
||||
);
|
||||
expect(docsConfigContents).toContain(
|
||||
'const PLAUSIBLE_ENDPOINT = `${PLAUSIBLE_PROXY_HOSTNAME}/api/event`',
|
||||
);
|
||||
expect(docsConfigContents).toContain('hostname: DOCS_HOSTNAME');
|
||||
expect(docsConfigContents).toContain("rel: 'preconnect'");
|
||||
expect(docsConfigContents).toContain('href: PLAUSIBLE_PROXY_HOSTNAME');
|
||||
expect(docsConfigContents).toContain("async: ''");
|
||||
expect(docsConfigContents).toContain(
|
||||
'src: `${PLAUSIBLE_PROXY_HOSTNAME}${PLAUSIBLE_SITE_SCRIPT_PATH}`',
|
||||
);
|
||||
expect(docsConfigContents).toContain('plausible.init({ endpoint:');
|
||||
expect(docsConfigContents).toContain('PLAUSIBLE_ENDPOINT');
|
||||
expect(docsConfigContents).not.toContain("'data-domain'");
|
||||
expect(docsConfigContents).not.toContain("'data-api'");
|
||||
expect(docsThemeContents).not.toContain('@plausible-analytics/tracker');
|
||||
expect(docsThemeContents).not.toContain('initPlausibleTracker');
|
||||
expect(docsPackageContents).not.toContain('@plausible-analytics/tracker');
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface Args {
|
||||
dictionaryTarget?: string;
|
||||
doctor: boolean;
|
||||
doctorRefreshKnownWords: boolean;
|
||||
update?: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
mpvIdle: boolean;
|
||||
|
||||
+14
-1
@@ -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"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
- **Character Dictionary:** Added AniList-based selection to resolve character dictionary mismatches, with series-scoped overrides that replace stale entries. Available via `subminer dictionary --candidates` / `--select` and a default `Ctrl+Alt+A` in-app shortcut.
|
||||
- **Subtitle Bar Toggle:** Added a `V` shortcut and mpv binding to toggle the primary subtitle bar independently of mpv's native subtitle display.
|
||||
- **Texthooker:** Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser.
|
||||
|
||||
### Changed
|
||||
|
||||
- **mpv Plugin Setup:** Managed launches now inject the bundled plugin automatically. The setup flow can trash detected legacy global plugin files before launch, and legacy global install entrypoints have been removed so regular mpv playback is unaffected.
|
||||
- **Tray Menu:** Replaced "Open Overlay" with "Open Help," which opens the session help modal.
|
||||
- **Stats Exclusions:** Vocabulary exclusions now persist in the immersion database and migrate existing browser-local exclusions on first load.
|
||||
- **Config Defaults:** Disabled texthooker startup, subtitle, and annotation websocket servers by default. Fresh installs now use a Japanese font stack, transparent subtitle backgrounds, stronger text shadows, and teal N4/fourth-band coloring for primary subtitles. Yomitan popup auto-pause remains enabled.
|
||||
- **Config Example:** The generated example config now lists every built-in keybinding default.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Subtitle Annotations — Grammar Filtering:** Suppressed N+1, JLPT, frequency, and name styling on grammar-only tokens: standalone interjections (`あ`, katakana variants), kana grammar helpers (`ことに`), auxiliary inflection fragments (`れる`, `れた`), polite copula tails (`です`, `じゃないですか`), standalone particles matched by known-word decks, and existence verbs (`ある`/`有る`). Known-word highlighting is preserved where applicable.
|
||||
- **Subtitle Annotations — Color Priority:** Fixed token color priority so typography settings are preserved, JLPT colors no longer override higher-priority known-word or frequency colors, JLPT underlines persist at their correct color after dictionary lookups and when a token also carries known-word or frequency annotations, and frequency highlighting works correctly for ordinal prefix-noun tokens like `第二`.
|
||||
- **Subtitle Annotations — Other:** Stopped kana-only tokens from being selected as N+1 targets; preserved Yomitan compound tokens so known component words no longer color a larger unknown word green; kept annotation prefetch running after immediate cache-hit renders; added a brightness lift for annotated token hover states when hover backgrounds are transparent; accepted `subtitleStyle.hoverBackground` as an alias for `subtitleStyle.hoverTokenBackgroundColor`; refreshed the current subtitle after successful card mining so newly known words recolor immediately.
|
||||
- **Subtitle Bar:** Changed `v` to cycle the primary subtitle bar through visible, hover, and hidden modes with OSD feedback. Added `subtitleStyle.primaryDefaultMode` to set the startup visibility default independently from secondary subtitles.
|
||||
- **Tokenizer:** Now uses Yomitan `wordClasses` metadata for part-of-speech filtering, and backfills blank MeCab POS fields during parser enrichment.
|
||||
- **Overlay (Linux):** Fixed multi-line subtitle copy timing out after the prompt; follow-up number-row digits are now accepted for multi-line mining even when the original shortcut modifiers are still held.
|
||||
- **Overlay (Hyprland):** Fixed fullscreen transitions so overlay geometry refreshes on mpv fullscreen changes, topmost stacking is reasserted, and hover pause works correctly after resize/toggle cycles. Overlay windows now align precisely to mpv bounds with floating decoration disabled; the stats overlay is opaque to prevent mpv bleed-through at the top edge; overlay windows no longer pin across workspaces.
|
||||
- **Overlay (macOS):** Kept the overlay visible and interactive during transient tracker refreshes while mpv is the active tracked window, and kept it behind unrelated foreground windows while remaining above mpv.
|
||||
- **Overlay:** Keyboard-only Yomitan popup shortcuts now take precedence over overlay keybindings like `j`; the browser focus outline is hidden so focused overlays no longer show a yellow/orange viewport border.
|
||||
- **Default Keybindings:** Fixed replay/next subtitle keybindings — session help moved to `Ctrl/Cmd+/`, freeing `Ctrl+Shift+H` and `Ctrl+Shift+L` for subtitle playback controls. `Ctrl+Shift+L` now correctly reaches play-next-subtitle, and play-next resumes from a paused state before pausing again at the subtitle end.
|
||||
- **Anki:** Manual clipboard subtitle updates preserve existing word audio while replacing sentence audio, animated-image media, and expression fields — even when audio overwrite is configured off.
|
||||
- **AniList:** Post-watch progress checks now run on time-position updates using the fresh mpv position; manual mark-watched forces a progress sync; missing episode metadata is filled from the filename parser. Duplicate writes during concurrent checks are prevented, and manual watched marks are preserved when sync fails.
|
||||
- **AniList (Linux):** Retried safeStorage availability after transient keyring failures so tokens can load and save once the keyring becomes available. Prevented config reload from opening the setup window during playback when token storage cannot be resolved, and stopped the setup flow from reporting success when token persistence fails.
|
||||
- **mpv:** Stopped mpv from holding SubMiner subprocesses during shutdown, preventing desktop crash notifications on video close. Kept the overlay alive across same-media buffering reloads to avoid duplicate startup gates and AniSkip lookups; playlist navigation now reuses the running overlay without repeating the pause-until-ready warmup gate.
|
||||
- **Launcher:** Managed playback now exits the background SubMiner app when the video closes; explicit background launches remain persistent.
|
||||
- **Stats:** Background mode routes through the isolated stats daemon; app startup defers to an already-running daemon instead of failing when the port is already in use. Fixed recent session detail pages showing "Media not found" before lifetime media summaries are available.
|
||||
- **Jellyfin:** Improved setup with recent server selection and inline authentication feedback. Added a tray toggle for runtime-only cast discovery.
|
||||
|
||||
### Docs
|
||||
|
||||
- Improved the docs homepage with canonical URLs and a cleaner sitemap.
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -25,11 +25,6 @@ private struct WindowState {
|
||||
let focused: Bool
|
||||
}
|
||||
|
||||
private enum WindowLookupResult {
|
||||
case visible(WindowState)
|
||||
case minimized
|
||||
}
|
||||
|
||||
private let targetMpvSocketPath: String? = {
|
||||
guard CommandLine.arguments.count > 1 else {
|
||||
return nil
|
||||
@@ -150,7 +145,7 @@ private func frontmostApplicationPid() -> pid_t? {
|
||||
NSWorkspace.shared.frontmostApplication?.processIdentifier
|
||||
}
|
||||
|
||||
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||
private func windowStateFromAccessibilityAPI() -> WindowState? {
|
||||
let runningApps = NSWorkspace.shared.runningApplications.filter { app in
|
||||
guard let name = app.localizedName else {
|
||||
return false
|
||||
@@ -159,7 +154,6 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||
}
|
||||
|
||||
let frontmostPid = frontmostApplicationPid()
|
||||
var foundMinimizedTargetWindow = false
|
||||
|
||||
for app in runningApps {
|
||||
let appElement = AXUIElementCreateApplication(app.processIdentifier)
|
||||
@@ -177,7 +171,6 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||
var minimizedRef: CFTypeRef?
|
||||
let minimizedStatus = AXUIElementCopyAttributeValue(window, kAXMinimizedAttribute as CFString, &minimizedRef)
|
||||
if minimizedStatus == .success, let minimized = minimizedRef as? Bool, minimized {
|
||||
foundMinimizedTargetWindow = true
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -191,20 +184,14 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||
}
|
||||
|
||||
if let geometry = geometryFromAXWindow(window) {
|
||||
return .visible(
|
||||
WindowState(
|
||||
geometry: geometry,
|
||||
focused: frontmostPid == windowPid
|
||||
)
|
||||
return WindowState(
|
||||
geometry: geometry,
|
||||
focused: frontmostPid == windowPid
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if foundMinimizedTargetWindow {
|
||||
return .minimized
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -263,25 +250,10 @@ private func windowStateFromCoreGraphics() -> WindowState? {
|
||||
return nil
|
||||
}
|
||||
|
||||
private let lookupResult: WindowLookupResult? = {
|
||||
if let axResult = windowStateFromAccessibilityAPI() {
|
||||
return axResult
|
||||
}
|
||||
if let cgWindow = windowStateFromCoreGraphics() {
|
||||
return .visible(cgWindow)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
if let result = lookupResult {
|
||||
switch result {
|
||||
case .visible(let window):
|
||||
print(
|
||||
"\(window.geometry.x),\(window.geometry.y),\(window.geometry.width),\(window.geometry.height),\(window.focused ? 1 : 0)"
|
||||
)
|
||||
case .minimized:
|
||||
print("minimized")
|
||||
}
|
||||
if let window = windowStateFromAccessibilityAPI() ?? windowStateFromCoreGraphics() {
|
||||
print(
|
||||
"\(window.geometry.x),\(window.geometry.y),\(window.geometry.width),\(window.geometry.height),\(window.focused ? 1 : 0)"
|
||||
)
|
||||
} else {
|
||||
print("not-found")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\./,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -547,7 +547,7 @@ test('initializeOverlayRuntime hides overlay windows when tracker loses the targ
|
||||
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime refreshes visible overlay on tracker loss when target is not minimized', () => {
|
||||
test('initializeOverlayRuntime hides visible overlay on Windows tracker loss when target is not minimized', () => {
|
||||
const calls: string[] = [];
|
||||
const tracker = {
|
||||
onGeometryChange: null as ((...args: unknown[]) => void) | null,
|
||||
@@ -600,7 +600,7 @@ test('initializeOverlayRuntime refreshes visible overlay on tracker loss when ta
|
||||
calls.length = 0;
|
||||
tracker.onWindowLost?.();
|
||||
|
||||
assert.deepEqual(calls, ['update-visible']);
|
||||
assert.deepEqual(calls, ['hide-visible', 'sync-shortcuts']);
|
||||
});
|
||||
|
||||
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {
|
||||
|
||||
@@ -105,14 +105,10 @@ export function initializeOverlayRuntime(options: {
|
||||
};
|
||||
windowTracker.onWindowLost = () => {
|
||||
options.releaseOverlayOwner?.();
|
||||
if (windowTracker.isTargetWindowMinimized()) {
|
||||
for (const window of options.getOverlayWindows()) {
|
||||
window.hide();
|
||||
}
|
||||
options.syncOverlayShortcuts();
|
||||
return;
|
||||
for (const window of options.getOverlayWindows()) {
|
||||
window.hide();
|
||||
}
|
||||
options.updateVisibleOverlayVisibility();
|
||||
options.syncOverlayShortcuts();
|
||||
};
|
||||
windowTracker.onWindowFocusChange = () => {
|
||||
if (options.isVisibleOverlayVisible()) {
|
||||
|
||||
@@ -1398,55 +1398,6 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
|
||||
assert.ok(!calls.includes('show'));
|
||||
});
|
||||
|
||||
test('macOS preserves visible overlay level during non-minimized tracker loss', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => false,
|
||||
getGeometry: () => null,
|
||||
isTargetWindowFocused: () => false,
|
||||
isTargetWindowMinimized: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: () => {
|
||||
calls.push('loading-osd');
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('sync-layer'));
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('loading-osd'));
|
||||
});
|
||||
|
||||
test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => {
|
||||
const { window } = createMainWindowRecorder();
|
||||
const osdMessages: string[] = [];
|
||||
|
||||
@@ -94,27 +94,13 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
const wasVisible = mainWindow.isVisible();
|
||||
const isVisibleOverlayFocused =
|
||||
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
|
||||
const windowTracker = args.windowTracker;
|
||||
const canReportMacOSTargetMinimized =
|
||||
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
|
||||
const isTrackedMacOSTargetMinimized =
|
||||
canReportMacOSTargetMinimized &&
|
||||
windowTracker?.isTargetWindowMinimized() === true;
|
||||
const hasTransientMacOSTrackerLoss =
|
||||
args.isMacOSPlatform &&
|
||||
canReportMacOSTargetMinimized &&
|
||||
!!windowTracker &&
|
||||
!windowTracker.isTracking() &&
|
||||
!isTrackedMacOSTargetMinimized &&
|
||||
mainWindow.isVisible();
|
||||
const isTrackedMacOSTargetFocused =
|
||||
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
|
||||
!args.isMacOSPlatform || !args.windowTracker
|
||||
? true
|
||||
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
|
||||
const shouldReleaseMacOSOverlayLevel =
|
||||
args.isMacOSPlatform &&
|
||||
!!args.windowTracker &&
|
||||
!hasTransientMacOSTrackerLoss &&
|
||||
!isVisibleOverlayFocused &&
|
||||
!isTrackedMacOSTargetFocused;
|
||||
const shouldDefaultToPassthrough =
|
||||
@@ -288,17 +274,9 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
|
||||
const hasActiveMacOSTargetSignal =
|
||||
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
|
||||
const canReportMacOSTargetMinimized =
|
||||
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
|
||||
const isTrackedMacOSTargetMinimized =
|
||||
canReportMacOSTargetMinimized &&
|
||||
args.windowTracker.isTargetWindowMinimized();
|
||||
const shouldPreserveTransientTrackedOverlay =
|
||||
(args.isMacOSPlatform &&
|
||||
!isTrackedMacOSTargetMinimized &&
|
||||
(hasRetainedTrackedGeometry ||
|
||||
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
|
||||
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
|
||||
(hasRetainedTrackedGeometry || (mainWindow.isVisible() && hasActiveMacOSTargetSignal))) ||
|
||||
(args.isWindowsPlatform &&
|
||||
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
|
||||
!args.windowTracker.isTargetWindowMinimized());
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildYomitanSettingsUrl,
|
||||
configureYomitanSettingsWindowChrome,
|
||||
destroyYomitanSettingsWindow,
|
||||
showYomitanSettingsWindow,
|
||||
} from './yomitan-settings';
|
||||
|
||||
test('yomitan settings window removes default app menu quit action', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
configureYomitanSettingsWindowChrome({
|
||||
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
|
||||
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
|
||||
} as never);
|
||||
|
||||
assert.deepEqual(calls, ['auto-hide:true', 'menu:null']);
|
||||
});
|
||||
|
||||
test('yomitan settings URL disables the embedded popup preview', () => {
|
||||
assert.equal(
|
||||
buildYomitanSettingsUrl('abc123'),
|
||||
'chrome-extension://abc123/settings.html?popup-preview=false&subminer-settings-safe=true',
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -13,6 +13,39 @@ export interface OpenYomitanSettingsWindowOptions {
|
||||
onWindowClosed?: () => void;
|
||||
}
|
||||
|
||||
export function configureYomitanSettingsWindowChrome(
|
||||
settingsWindow: Pick<BrowserWindow, 'setAutoHideMenuBar' | 'setMenu'>,
|
||||
): void {
|
||||
settingsWindow.setAutoHideMenuBar(true);
|
||||
settingsWindow.setMenu(null);
|
||||
}
|
||||
|
||||
export function buildYomitanSettingsUrl(extensionId: string): string {
|
||||
return `chrome-extension://${extensionId}/settings.html?popup-preview=false&subminer-settings-safe=true`;
|
||||
}
|
||||
|
||||
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 +57,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 +68,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;
|
||||
@@ -76,12 +111,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
+202
-8
@@ -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 {
|
||||
@@ -473,13 +483,13 @@ import { createOverlayModalInputState } from './main/runtime/overlay-modal-input
|
||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
||||
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
||||
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
|
||||
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
|
||||
import { openCharacterDictionaryModal as openCharacterDictionaryModalRuntime } from './main/runtime/character-dictionary-open';
|
||||
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
|
||||
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
|
||||
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
|
||||
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
|
||||
import { openOverlayHostedModal } from './main/runtime/overlay-hosted-modal-open';
|
||||
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||
import {
|
||||
createFrequencyDictionaryRuntimeService,
|
||||
@@ -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) {
|
||||
@@ -1406,11 +1479,9 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
|
||||
},
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
openManualPicker: (payload) => {
|
||||
openOverlayHostedModalWithOsd(
|
||||
(deps) => openSubsyncManualModalRuntime(deps, payload),
|
||||
'Subsync overlay unavailable.',
|
||||
'Failed to open subsync overlay.',
|
||||
);
|
||||
sendToActiveOverlayWindow('subsync:open-manual', payload, {
|
||||
restoreOnModalClose: 'subsync',
|
||||
});
|
||||
},
|
||||
});
|
||||
const immersionMediaRuntime = createImmersionMediaRuntime(
|
||||
@@ -2751,6 +2822,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
mpvExecutablePath,
|
||||
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
|
||||
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
||||
commandLineLauncher: snapshot.commandLineLauncher,
|
||||
message: firstRunSetupMessage,
|
||||
};
|
||||
},
|
||||
@@ -2786,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.'
|
||||
@@ -3652,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),
|
||||
@@ -4520,6 +4604,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,
|
||||
@@ -5175,6 +5345,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(),
|
||||
@@ -5242,7 +5420,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) => ({
|
||||
@@ -5285,6 +5467,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;
|
||||
@@ -5369,6 +5557,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',
|
||||
@@ -5381,6 +5570,9 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
toggleJellyfinDiscovery: () =>
|
||||
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
openAnilistSetupWindow: () => openAnilistSetupWindow(),
|
||||
checkForUpdates: () => {
|
||||
void getUpdateService().checkForUpdates({ source: 'manual' });
|
||||
},
|
||||
quitApp: () => requestAppQuit(),
|
||||
},
|
||||
ensureTrayDeps: {
|
||||
@@ -5536,6 +5728,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
||||
});
|
||||
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
||||
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
|
||||
getYomitanExtension: () => appState.yomitanExt,
|
||||
getYomitanExtensionLoadInFlight: () => yomitanLoadInFlight,
|
||||
getYomitanSession: () => appState.yomitanSession,
|
||||
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {
|
||||
openYomitanSettingsWindow({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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>',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { openSubsyncManualModal } from './subsync-open';
|
||||
import type { SubsyncManualPayload } from '../../types';
|
||||
|
||||
const payload: SubsyncManualPayload = {
|
||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
||||
};
|
||||
|
||||
test('subsync manual open prefers dedicated modal window on first attempt', async () => {
|
||||
const sends: Array<{
|
||||
channel: string;
|
||||
payload: SubsyncManualPayload;
|
||||
options: {
|
||||
restoreOnModalClose: 'subsync';
|
||||
preferModalWindow: boolean;
|
||||
};
|
||||
}> = [];
|
||||
|
||||
const opened = await openSubsyncManualModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||
sendToActiveOverlayWindow: (channel, nextPayload, options) => {
|
||||
sends.push({
|
||||
channel,
|
||||
payload: nextPayload as SubsyncManualPayload,
|
||||
options: options as {
|
||||
restoreOnModalClose: 'subsync';
|
||||
preferModalWindow: boolean;
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async (modal, timeoutMs) => {
|
||||
assert.equal(modal, 'subsync');
|
||||
assert.equal(timeoutMs, 1500);
|
||||
return true;
|
||||
},
|
||||
logWarn: () => {
|
||||
throw new Error('should not warn on first-attempt success');
|
||||
},
|
||||
},
|
||||
payload,
|
||||
);
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(sends, [
|
||||
{
|
||||
channel: 'subsync:open-manual',
|
||||
payload,
|
||||
options: {
|
||||
restoreOnModalClose: 'subsync',
|
||||
preferModalWindow: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('subsync manual open retries on the dedicated modal window after open timeout', async () => {
|
||||
const preferModalWindowValues: boolean[] = [];
|
||||
const warnings: string[] = [];
|
||||
let waitCalls = 0;
|
||||
|
||||
const opened = await openSubsyncManualModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||
sendToActiveOverlayWindow: (_channel, _payload, options) => {
|
||||
preferModalWindowValues.push(Boolean(options?.preferModalWindow));
|
||||
return true;
|
||||
},
|
||||
waitForModalOpen: async () => {
|
||||
waitCalls += 1;
|
||||
return waitCalls === 2;
|
||||
},
|
||||
logWarn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
},
|
||||
payload,
|
||||
);
|
||||
|
||||
assert.equal(opened, true);
|
||||
assert.deepEqual(preferModalWindowValues, [true, true]);
|
||||
assert.deepEqual(warnings, [
|
||||
'Subsync modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('subsync manual open fails when the dedicated modal window cannot be targeted', async () => {
|
||||
let waitCalls = 0;
|
||||
|
||||
const opened = await openSubsyncManualModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: () => {},
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => {},
|
||||
sendToActiveOverlayWindow: () => false,
|
||||
waitForModalOpen: async () => {
|
||||
waitCalls += 1;
|
||||
return true;
|
||||
},
|
||||
logWarn: () => {},
|
||||
},
|
||||
payload,
|
||||
);
|
||||
|
||||
assert.equal(opened, false);
|
||||
assert.equal(waitCalls, 0);
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { SubsyncManualPayload } from '../../types';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
const SUBSYNC_MODAL: OverlayHostedModal = 'subsync';
|
||||
const SUBSYNC_OPEN_TIMEOUT_MS = 1500;
|
||||
|
||||
export async function openSubsyncManualModal(
|
||||
deps: {
|
||||
ensureOverlayStartupPrereqs: () => void;
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
runtimeOptions?: {
|
||||
restoreOnModalClose?: OverlayHostedModal;
|
||||
preferModalWindow?: boolean;
|
||||
},
|
||||
) => boolean;
|
||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
||||
logWarn: (message: string) => void;
|
||||
},
|
||||
payload: SubsyncManualPayload,
|
||||
): Promise<boolean> {
|
||||
return await retryOverlayModalOpen(
|
||||
{
|
||||
waitForModalOpen: deps.waitForModalOpen,
|
||||
logWarn: deps.logWarn,
|
||||
},
|
||||
{
|
||||
modal: SUBSYNC_MODAL,
|
||||
timeoutMs: SUBSYNC_OPEN_TIMEOUT_MS,
|
||||
retryWarning:
|
||||
'Subsync modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(
|
||||
{
|
||||
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
|
||||
ensureOverlayWindowsReadyForVisibilityActions:
|
||||
deps.ensureOverlayWindowsReadyForVisibilityActions,
|
||||
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.event.subsyncOpenManual,
|
||||
modal: SUBSYNC_MODAL,
|
||||
payload,
|
||||
preferModalWindow: 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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user