mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-10 04:19:25 -07:00
Prepare Windows release and signing
# Conflicts: # package.json # Conflicts: # .github/workflows/release.yml # README.md # package.json # plugin/subminer/lifecycle.lua # scripts/build-yomitan.mjs # src/core/services/overlay-window.ts # src/main.ts # src/main/overlay-shortcuts-runtime.ts # src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts # src/main/runtime/overlay-shortcuts-runtime-main-deps.ts # src/window-trackers/base-tracker.ts
This commit is contained in:
106
.github/workflows/release.yml
vendored
106
.github/workflows/release.yml
vendored
@@ -10,6 +10,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
@@ -211,8 +212,105 @@ jobs:
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
|
||||
build-windows:
|
||||
needs: [quality-gate]
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.5
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.12.0
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
vendor/texthooker-ui/node_modules
|
||||
vendor/subminer-yomitan/node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Validate Windows signing secrets
|
||||
shell: bash
|
||||
run: |
|
||||
missing=0
|
||||
for name in SIGNPATH_API_TOKEN SIGNPATH_ORGANIZATION_ID SIGNPATH_PROJECT_SLUG SIGNPATH_SIGNING_POLICY_SLUG; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "Missing required secret: $name"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
if [ "$missing" -ne 0 ]; then
|
||||
echo "Set the SignPath Windows signing secrets and rerun."
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
SIGNPATH_ORGANIZATION_ID: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||
SIGNPATH_PROJECT_SLUG: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
|
||||
SIGNPATH_SIGNING_POLICY_SLUG: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build texthooker-ui
|
||||
shell: powershell
|
||||
run: |
|
||||
Set-Location vendor/texthooker-ui
|
||||
bun install
|
||||
bun run build
|
||||
|
||||
- name: Build unsigned Windows artifacts
|
||||
run: bun run build:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload unsigned Windows artifact for SignPath
|
||||
id: upload-unsigned-windows-artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unsigned-windows
|
||||
path: |
|
||||
release/*.exe
|
||||
release/*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit Windows signing request
|
||||
id: signpath-sign
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
|
||||
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
|
||||
github-artifact-id: ${{ steps.upload-unsigned-windows-artifact.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: signed-windows
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload signed Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
path: |
|
||||
signed-windows/*.exe
|
||||
signed-windows/*.zip
|
||||
|
||||
release:
|
||||
needs: [build-linux, build-macos]
|
||||
needs: [build-linux, build-macos, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -232,6 +330,12 @@ jobs:
|
||||
name: macos
|
||||
path: release
|
||||
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
path: release
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
|
||||
51
Makefile
51
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows install-plugin uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
||||
|
||||
APP_NAME := subminer
|
||||
THEME_SOURCE := assets/themes/subminer.rasi
|
||||
@@ -20,11 +20,6 @@ MACOS_DATA_DIR ?= $(HOME)/Library/Application Support/SubMiner
|
||||
MACOS_APP_DIR ?= $(HOME)/Applications
|
||||
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
||||
|
||||
# mpv plugin install directories.
|
||||
MPV_CONFIG_DIR ?= $(HOME)/.config/mpv
|
||||
MPV_SCRIPTS_DIR ?= $(MPV_CONFIG_DIR)/scripts
|
||||
MPV_SCRIPT_OPTS_DIR ?= $(MPV_CONFIG_DIR)/script-opts
|
||||
|
||||
# If building from source, the AppImage will typically land in release/.
|
||||
APPIMAGE_SRC := $(firstword $(wildcard release/SubMiner-*.AppImage))
|
||||
MACOS_APP_SRC := $(firstword $(wildcard release/*.app release/*/*.app))
|
||||
@@ -41,6 +36,17 @@ else
|
||||
PLATFORM := unknown
|
||||
endif
|
||||
|
||||
WINDOWS_APPDATA ?= $(if $(APPDATA),$(subst \,/,$(APPDATA)),$(HOME)/AppData/Roaming)
|
||||
|
||||
# mpv plugin install directories.
|
||||
ifeq ($(PLATFORM),windows)
|
||||
MPV_CONFIG_DIR ?= $(WINDOWS_APPDATA)/mpv
|
||||
else
|
||||
MPV_CONFIG_DIR ?= $(HOME)/.config/mpv
|
||||
endif
|
||||
MPV_SCRIPTS_DIR ?= $(MPV_CONFIG_DIR)/scripts
|
||||
MPV_SCRIPT_OPTS_DIR ?= $(MPV_CONFIG_DIR)/script-opts
|
||||
|
||||
help:
|
||||
@printf '%s\n' \
|
||||
"Targets:" \
|
||||
@@ -58,6 +64,7 @@ help:
|
||||
" dev-stop Stop a running local Electron app" \
|
||||
" install-linux Install Linux wrapper/theme/app artifacts" \
|
||||
" install-macos Install macOS wrapper/theme/app artifacts" \
|
||||
" install-windows Install Windows mpv plugin artifacts" \
|
||||
" install-plugin Install mpv Lua plugin and plugin config" \
|
||||
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
||||
"" \
|
||||
@@ -65,6 +72,7 @@ help:
|
||||
" deps Install JS dependencies (root + texthooker-ui)" \
|
||||
" uninstall-linux Remove Linux install artifacts" \
|
||||
" uninstall-macos Remove macOS install artifacts" \
|
||||
" uninstall-windows Remove Windows mpv plugin artifacts" \
|
||||
" print-dirs Show resolved install locations" \
|
||||
"" \
|
||||
"Variables:" \
|
||||
@@ -74,7 +82,7 @@ help:
|
||||
" LINUX_DATA_DIR=... Override Linux app data dir" \
|
||||
" MACOS_DATA_DIR=... Override macOS app data dir" \
|
||||
" MACOS_APP_DIR=... Override macOS app install dir (default: $$HOME/Applications)" \
|
||||
" MPV_CONFIG_DIR=... Override mpv config dir (default: $$HOME/.config/mpv)"
|
||||
" MPV_CONFIG_DIR=... Override mpv config dir (default: $$HOME/.config/mpv or %APPDATA%/mpv on Windows)"
|
||||
|
||||
print-dirs:
|
||||
@printf '%s\n' \
|
||||
@@ -85,6 +93,10 @@ print-dirs:
|
||||
"MACOS_DATA_DIR=$(MACOS_DATA_DIR)" \
|
||||
"MACOS_APP_DIR=$(MACOS_APP_DIR)" \
|
||||
"MACOS_APP_DEST=$(MACOS_APP_DEST)" \
|
||||
"WINDOWS_APPDATA=$(WINDOWS_APPDATA)" \
|
||||
"MPV_CONFIG_DIR=$(MPV_CONFIG_DIR)" \
|
||||
"MPV_SCRIPTS_DIR=$(MPV_SCRIPTS_DIR)" \
|
||||
"MPV_SCRIPT_OPTS_DIR=$(MPV_SCRIPT_OPTS_DIR)" \
|
||||
"APPIMAGE_SRC=$(APPIMAGE_SRC)" \
|
||||
"MACOS_APP_SRC=$(MACOS_APP_SRC)" \
|
||||
"MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)"
|
||||
@@ -113,6 +125,7 @@ install:
|
||||
@case "$(PLATFORM)" in \
|
||||
linux) $(MAKE) --no-print-directory install-linux ;; \
|
||||
macos) $(MAKE) --no-print-directory install-macos ;; \
|
||||
windows) $(MAKE) --no-print-directory install-windows ;; \
|
||||
*) printf '%s\n' "[ERROR] Unsupported OS for this Makefile target: $(PLATFORM)"; exit 1 ;; \
|
||||
esac
|
||||
|
||||
@@ -210,18 +223,31 @@ install-macos: build-launcher
|
||||
fi
|
||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
|
||||
|
||||
install-windows:
|
||||
@printf '%s\n' "[INFO] Installing Windows mpv plugin artifacts"
|
||||
@$(MAKE) --no-print-directory install-plugin
|
||||
|
||||
install-plugin:
|
||||
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
|
||||
@install -d "$(MPV_SCRIPTS_DIR)"
|
||||
@rm -f "$(MPV_SCRIPTS_DIR)/subminer.lua"
|
||||
@rm -f "$(MPV_SCRIPTS_DIR)/subminer.lua" "$(MPV_SCRIPTS_DIR)/subminer-loader.lua"
|
||||
@install -d "$(MPV_SCRIPTS_DIR)/subminer"
|
||||
@install -d "$(MPV_SCRIPT_OPTS_DIR)"
|
||||
@cp -R ./plugin/subminer/. "$(MPV_SCRIPTS_DIR)/subminer/"
|
||||
@install -m 0644 "./$(PLUGIN_CONF)" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||
@if [ "$(PLATFORM)" = "windows" ]; then \
|
||||
node ./scripts/configure-plugin-binary-path.mjs "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf" "$(CURDIR)" win32; \
|
||||
fi
|
||||
@printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer/main.lua" " $(MPV_SCRIPTS_DIR)/subminer/" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||
|
||||
# Uninstall behavior kept unchanged by default.
|
||||
uninstall: uninstall-linux
|
||||
uninstall:
|
||||
@printf '%s\n' "[INFO] Detected platform: $(PLATFORM)"
|
||||
@case "$(PLATFORM)" in \
|
||||
linux) $(MAKE) --no-print-directory uninstall-linux ;; \
|
||||
macos) $(MAKE) --no-print-directory uninstall-macos ;; \
|
||||
windows) $(MAKE) --no-print-directory uninstall-windows ;; \
|
||||
*) printf '%s\n' "[ERROR] Unsupported OS for this Makefile target: $(PLATFORM)"; exit 1 ;; \
|
||||
esac
|
||||
|
||||
uninstall-linux:
|
||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||
@@ -233,3 +259,8 @@ uninstall-macos:
|
||||
@rm -f "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)"
|
||||
@rm -rf "$(MACOS_APP_DEST)"
|
||||
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
|
||||
|
||||
uninstall-windows:
|
||||
@rm -rf "$(MPV_SCRIPTS_DIR)/subminer"
|
||||
@rm -f "$(MPV_SCRIPTS_DIR)/subminer.lua" "$(MPV_SCRIPTS_DIR)/subminer-loader.lua" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||
@printf '%s\n' "Removed:" " $(MPV_SCRIPTS_DIR)/subminer" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||
|
||||
49
README.md
49
README.md
@@ -5,7 +5,7 @@
|
||||
<br /><br />
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[]()
|
||||
[]()
|
||||
[](https://docs.subminer.moe)
|
||||
|
||||
</div>
|
||||
@@ -20,17 +20,24 @@
|
||||
|
||||
<br />
|
||||
|
||||
Initial packaged Windows support is now available alongside the existing Linux and macOS builds.
|
||||
|
||||
## What it does
|
||||
|
||||
SubMiner is an Electron overlay that sits on top of mpv. It turns your video player into a full sentence-mining workstation:
|
||||
|
||||
- **Dictionary lookups** — Yomitan popups on subtitles with hover or full keyboard-driven navigation; hover-aware auto-pause keeps playback in sync
|
||||
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and AI-powered translation
|
||||
- **Reading annotations** — N+1 targeting, frequency highlighting, and JLPT underlining while you watch
|
||||
- **Subtitle tools** — Jimaku downloads, alass/ffsubsync sync, and YouTube subtitle generation via manual-track reuse plus whisper.cpp fallback with optional AI cleanup
|
||||
- **Texthooker** — Built-in texthooker page and annotated websocket API for external clients
|
||||
- **Immersion tracking** — SQLite-powered stats on watch time and mining activity
|
||||
- **Integrations** — Jellyfin remote playback, AniList episode progress, and AnkiConnect auto-enrichment
|
||||
- **Hover to look up** — Yomitan dictionary popups directly on subtitles
|
||||
- **Keyboard-driven lookup mode** — Navigate token-by-token, keep lookup open across tokens, and control popup scrolling/audio/mining without leaving the overlay
|
||||
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
|
||||
- **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately
|
||||
- **Reading annotations** — Combines N+1 targeting, frequency-dictionary highlighting, and JLPT underlining while you read
|
||||
- **Hover-aware playback** — By default, hovering subtitle text pauses mpv and resumes on mouse leave (`subtitleStyle.autoPauseVideoOnHover`)
|
||||
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
|
||||
- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity
|
||||
- **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup
|
||||
- **Annotated websocket API** — Dedicated annotation feed can serve bundled texthooker or external clients with rendered `sentence` HTML plus structured `tokens`
|
||||
- **Jellyfin integration** — Remote playback setup, cast device mode, and direct playback launch
|
||||
- **AniList progress** — Track episode completion and push watching progress automatically
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -49,14 +56,21 @@ chmod +x ~/.local/bin/subminer
|
||||
> [!NOTE]
|
||||
> The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. Make sure `bun` is on your `PATH`.
|
||||
|
||||
**From source** or **macOS** — initialize submodules first (`git submodule update --init --recursive`). Bundled Yomitan is built natively with Bun from the `vendor/subminer-yomitan` submodule into `build/yomitan` during `bun run build`, so Bun is the only JS runtime/package manager required for source builds. Full install guide: [docs.subminer.moe/installation#from-source](https://docs.subminer.moe/installation#from-source).
|
||||
**macOS (DMG/ZIP):** download the latest packaged build from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
|
||||
|
||||
**Windows (Installer/ZIP):** download the latest `SubMiner-<version>.exe` installer or portable `.zip` from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Keep `mpv` installed and available on `PATH`.
|
||||
|
||||
**From source** — initialize submodules first (`git submodule update --init --recursive`). Source builds now also require Node.js 22 + npm because bundled Yomitan is built from the `vendor/subminer-yomitan` submodule into `build/yomitan` during `bun run build`. Full install guide: [docs.subminer.moe/installation#from-source](https://docs.subminer.moe/installation#from-source).
|
||||
|
||||
### 2. Launch the app once
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
SubMiner.AppImage
|
||||
```
|
||||
|
||||
On macOS, launch `SubMiner.app`. On Windows, launch `SubMiner.exe` from the Start menu or install directory.
|
||||
|
||||
On first launch, SubMiner now:
|
||||
|
||||
- starts in the tray/background
|
||||
@@ -87,16 +101,29 @@ subminer --start video.mkv # optional explicit overlay start when plugin auto_st
|
||||
|
||||
| Required | Optional |
|
||||
| ------------------------------------------ | -------------------------------------------------- |
|
||||
| `bun` | |
|
||||
| `bun`, `node` 22, `npm` | |
|
||||
| `mpv` with IPC socket | `yt-dlp` |
|
||||
| `ffmpeg` | `guessit` (better AniSkip title/episode detection) |
|
||||
| `mecab` + `mecab-ipadic` | `fzf` / `rofi` |
|
||||
| Linux: `hyprctl` or `xdotool` + `xwininfo` | `chafa`, `ffmpegthumbnailer` |
|
||||
| macOS: Accessibility permission | |
|
||||
|
||||
Windows builds use native window tracking and do not require the Linux compositor helper tools.
|
||||
|
||||
## Documentation
|
||||
|
||||
For full guides on configuration, Anki, Jellyfin, and more, see [docs.subminer.moe](https://docs.subminer.moe). Contributor setup, build, and testing docs now live in the docs repo: [docs.subminer.moe/development#testing](https://docs.subminer.moe/development#testing).
|
||||
For full guides on configuration, Anki, Jellyfin, and more, see [docs.subminer.moe](https://docs.subminer.moe).
|
||||
|
||||
## Testing
|
||||
|
||||
- Run `bun run test` or `bun run test:fast` for the default fast lane: config/core coverage plus representative entry/runtime, Anki integration, and main runtime checks.
|
||||
- Run `bun run test:full` for the maintained test surface: Bun-compatible `src/**` coverage, Bun-compatible launcher unit coverage, and a Node compatibility lane for suites that depend on Electron named exports or `node:sqlite` behavior.
|
||||
- Run `bun run test:node:compat` directly when you only need the Node-backed compatibility slice: `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and runtime registry coverage.
|
||||
- Run `bun run test:env` for environment-specific verification: launcher smoke/plugin checks plus the SQLite-backed immersion tracker lane.
|
||||
- Run `bun run test:immersion:sqlite` when you specifically need real SQLite persistence coverage under Node with `--experimental-sqlite`.
|
||||
- Run `bun run test:subtitle` for the maintained `alass`/`ffsubsync` subtitle surface.
|
||||
|
||||
The Bun-managed discovery lanes intentionally exclude a small set of suites that are currently Node-only because of Bun runtime/tooling gaps rather than product behavior: Electron named-export tests in `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, and `src/core/services/overlay-manager.test.ts`, plus runtime/config tests in `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:node:compat` keeps those suites in the standard workflow instead of leaving them untracked.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
id: TASK-117
|
||||
title: Prepare initial Windows release docs and version bump
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-08 15:17'
|
||||
updated_date: '2026-03-08 15:17'
|
||||
labels:
|
||||
- release
|
||||
- docs
|
||||
- windows
|
||||
dependencies: []
|
||||
references:
|
||||
- package.json
|
||||
- README.md
|
||||
- ../subminer-docs/installation.md
|
||||
- ../subminer-docs/usage.md
|
||||
- ../subminer-docs/changelog.md
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Prepare the initial packaged Windows release by bumping the app version and refreshing the release-facing README/backlog/docs surfaces so install and direct-command guidance no longer reads Linux-only.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 App version is bumped for the Windows release cut
|
||||
- [x] #2 README and sibling docs describe Windows packaged installation alongside Linux/macOS guidance
|
||||
- [x] #3 Backlog records the release-doc/version update with the modified references
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Bump the package version for the release cut.
|
||||
2. Update the root README install/start guidance to mention Windows packaged builds.
|
||||
3. Patch the sibling docs repo installation, usage, and changelog pages for the Windows release.
|
||||
4. Record the work in Backlog and run targeted verification on the touched surfaces.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
The public README still advertised Linux/macOS only, while the sibling docs had Windows-specific runtime notes but no actual Windows install section and several direct-command examples still assumed `SubMiner.AppImage`.
|
||||
|
||||
Bumped `package.json` to `0.5.0`, expanded the README platform/install copy to include Windows, added a Windows install section to `../subminer-docs/installation.md`, clarified in `../subminer-docs/usage.md` that direct packaged-app examples use `SubMiner.exe` on Windows, and added a `v0.5.0` changelog entry covering the initial Windows release plus the latest overlay behavior polish.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Prepared the initial Windows release documentation pass and version bump. `package.json` now reports `0.5.0`. The root `README.md` now advertises Linux, macOS, and Windows support, includes Windows packaged-install guidance, and clarifies first-launch behavior across platforms. In the sibling docs repo, `installation.md` now includes a dedicated Windows install section, `usage.md` explains that direct packaged-app examples use `SubMiner.exe` on Windows, and `changelog.md` now includes the `v0.5.0` release notes for the initial Windows build and recent overlay behavior changes.
|
||||
|
||||
Verification: targeted `bun run tsc --noEmit -p tsconfig.typecheck.json` in the app repo and `bun run docs:build` in `../subminer-docs`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
id: TASK-118
|
||||
title: Add Windows release build and SignPath signing
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-08 15:17'
|
||||
updated_date: '2026-03-08 15:17'
|
||||
labels:
|
||||
- release
|
||||
- windows
|
||||
- signing
|
||||
dependencies: []
|
||||
references:
|
||||
- .github/workflows/release.yml
|
||||
- build/installer.nsh
|
||||
- build/signpath-windows-artifact-config.xml
|
||||
- package.json
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Extend the tag-driven release workflow so Windows artifacts are built on GitHub-hosted runners and submitted to SignPath for free open-source Authenticode signing, while preserving the existing macOS notarization path.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Release workflow builds Windows installer and ZIP artifacts on `windows-latest`
|
||||
- [x] #2 Workflow submits unsigned Windows artifacts to SignPath and uploads the signed outputs for release publication
|
||||
- [x] #3 Repository includes a checked-in SignPath artifact-configuration source of truth for the Windows release files
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Inspect the existing release workflow and current Windows packaging configuration.
|
||||
2. Add a Windows release job that builds unsigned artifacts, uploads them as a workflow artifact, and submits them to SignPath.
|
||||
3. Update the release aggregation job to publish signed Windows assets and mention Windows install steps in the generated release notes.
|
||||
4. Check in the Windows SignPath artifact configuration XML used to define what gets signed.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
The repository already had Windows packaging configuration (`build:win`, NSIS include script, Windows helper asset packaging), but the release workflow still built Linux and macOS only.
|
||||
|
||||
Added a `build-windows` job to `.github/workflows/release.yml` that runs on `windows-latest`, validates required SignPath secrets, builds unsigned Windows artifacts, uploads them with `actions/upload-artifact@v4`, and then calls the official `signpath/github-action-submit-signing-request@v2` action to retrieve signed outputs.
|
||||
|
||||
Checked in `build/signpath-windows-artifact-config.xml` as the source-of-truth artifact configuration for SignPath. It signs the top-level NSIS installer EXE and deep-signs `.exe` and `.dll` files inside the portable ZIP artifact.
|
||||
|
||||
Updated the release aggregation job to download the signed Windows artifacts and added a Windows install section to the generated GitHub release body.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Windows release publishing is now wired into the tag-driven workflow. `.github/workflows/release.yml` builds Windows artifacts on `windows-latest`, submits them to SignPath using the official GitHub action, and publishes the signed `.exe` and `.zip` outputs alongside the Linux and macOS artifacts. The workflow now requests the additional `actions: read` permission required by the SignPath GitHub integration, and the generated release notes now include Windows installation steps.
|
||||
|
||||
The checked-in `build/signpath-windows-artifact-config.xml` file defines the SignPath artifact structure expected by the workflow artifact ZIP: sign the top-level `SubMiner-*.exe` installer and deep-sign `.exe` and `.dll` files inside `SubMiner-*.zip`.
|
||||
|
||||
Verification: workflow/static changes were checked with `git diff --check` on the touched files. Actual signing requires configured SignPath secrets and a matching artifact configuration in your SignPath project.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
150
build/installer.nsh
Normal file
150
build/installer.nsh
Normal file
@@ -0,0 +1,150 @@
|
||||
!include "MUI2.nsh"
|
||||
!include "nsDialogs.nsh"
|
||||
|
||||
Var WindowsMpvShortcutStartMenuPath
|
||||
Var WindowsMpvShortcutDesktopPath
|
||||
|
||||
!macro ResolveWindowsMpvShortcutPaths
|
||||
!ifdef MENU_FILENAME
|
||||
StrCpy $WindowsMpvShortcutStartMenuPath "$SMPROGRAMS\${MENU_FILENAME}\SubMiner mpv.lnk"
|
||||
!else
|
||||
StrCpy $WindowsMpvShortcutStartMenuPath "$SMPROGRAMS\SubMiner mpv.lnk"
|
||||
!endif
|
||||
StrCpy $WindowsMpvShortcutDesktopPath "$DESKTOP\SubMiner mpv.lnk"
|
||||
!macroend
|
||||
|
||||
!ifndef BUILD_UNINSTALLER
|
||||
Var WindowsMpvShortcutStartMenuCheckbox
|
||||
Var WindowsMpvShortcutDesktopCheckbox
|
||||
Var WindowsMpvShortcutStartMenuEnabled
|
||||
Var WindowsMpvShortcutDesktopEnabled
|
||||
Var WindowsMpvShortcutDefaultsInitialized
|
||||
|
||||
!macro customInit
|
||||
StrCpy $WindowsMpvShortcutStartMenuEnabled "1"
|
||||
StrCpy $WindowsMpvShortcutDesktopEnabled "1"
|
||||
StrCpy $WindowsMpvShortcutDefaultsInitialized "0"
|
||||
!macroend
|
||||
|
||||
!macro customPageAfterChangeDir
|
||||
PageEx custom
|
||||
PageCallbacks WindowsMpvShortcutPageCreate WindowsMpvShortcutPageLeave
|
||||
Caption " "
|
||||
PageExEnd
|
||||
!macroend
|
||||
|
||||
Function HasExistingInstallation
|
||||
ReadRegStr $0 SHELL_CONTEXT "Software\${APP_GUID}" InstallLocation
|
||||
${if} $0 == ""
|
||||
Push "0"
|
||||
${else}
|
||||
Push "1"
|
||||
${endif}
|
||||
FunctionEnd
|
||||
|
||||
Function InitializeWindowsMpvShortcutDefaults
|
||||
${if} $WindowsMpvShortcutDefaultsInitialized == "1"
|
||||
Return
|
||||
${endif}
|
||||
|
||||
!insertmacro ResolveWindowsMpvShortcutPaths
|
||||
Call HasExistingInstallation
|
||||
Pop $0
|
||||
|
||||
${if} $0 == "1"
|
||||
${if} ${FileExists} "$WindowsMpvShortcutStartMenuPath"
|
||||
StrCpy $WindowsMpvShortcutStartMenuEnabled "1"
|
||||
${else}
|
||||
StrCpy $WindowsMpvShortcutStartMenuEnabled "0"
|
||||
${endif}
|
||||
|
||||
${if} ${FileExists} "$WindowsMpvShortcutDesktopPath"
|
||||
StrCpy $WindowsMpvShortcutDesktopEnabled "1"
|
||||
${else}
|
||||
StrCpy $WindowsMpvShortcutDesktopEnabled "0"
|
||||
${endif}
|
||||
${else}
|
||||
StrCpy $WindowsMpvShortcutStartMenuEnabled "1"
|
||||
StrCpy $WindowsMpvShortcutDesktopEnabled "1"
|
||||
${endif}
|
||||
|
||||
StrCpy $WindowsMpvShortcutDefaultsInitialized "1"
|
||||
FunctionEnd
|
||||
|
||||
Function WindowsMpvShortcutPageCreate
|
||||
Call InitializeWindowsMpvShortcutDefaults
|
||||
|
||||
!insertmacro MUI_HEADER_TEXT "Windows mpv launcher" "Choose where to create the optional SubMiner mpv shortcuts."
|
||||
|
||||
nsDialogs::Create 1018
|
||||
Pop $0
|
||||
|
||||
${NSD_CreateLabel} 0u 0u 300u 30u "SubMiner mpv launches SubMiner.exe --launch-mpv so people can open mpv with the SubMiner profile from a separate Windows shortcut."
|
||||
Pop $0
|
||||
|
||||
${NSD_CreateCheckbox} 0u 44u 280u 12u "Create Start Menu shortcut"
|
||||
Pop $WindowsMpvShortcutStartMenuCheckbox
|
||||
${if} $WindowsMpvShortcutStartMenuEnabled == "1"
|
||||
${NSD_Check} $WindowsMpvShortcutStartMenuCheckbox
|
||||
${endif}
|
||||
|
||||
${NSD_CreateCheckbox} 0u 64u 280u 12u "Create Desktop shortcut"
|
||||
Pop $WindowsMpvShortcutDesktopCheckbox
|
||||
${if} $WindowsMpvShortcutDesktopEnabled == "1"
|
||||
${NSD_Check} $WindowsMpvShortcutDesktopCheckbox
|
||||
${endif}
|
||||
|
||||
${NSD_CreateLabel} 0u 90u 300u 24u "Upgrades preserve the current SubMiner mpv shortcut locations instead of recreating shortcuts you already removed."
|
||||
Pop $0
|
||||
|
||||
nsDialogs::Show
|
||||
FunctionEnd
|
||||
|
||||
Function WindowsMpvShortcutPageLeave
|
||||
${NSD_GetState} $WindowsMpvShortcutStartMenuCheckbox $0
|
||||
${if} $0 == ${BST_CHECKED}
|
||||
StrCpy $WindowsMpvShortcutStartMenuEnabled "1"
|
||||
${else}
|
||||
StrCpy $WindowsMpvShortcutStartMenuEnabled "0"
|
||||
${endif}
|
||||
|
||||
${NSD_GetState} $WindowsMpvShortcutDesktopCheckbox $0
|
||||
${if} $0 == ${BST_CHECKED}
|
||||
StrCpy $WindowsMpvShortcutDesktopEnabled "1"
|
||||
${else}
|
||||
StrCpy $WindowsMpvShortcutDesktopEnabled "0"
|
||||
${endif}
|
||||
FunctionEnd
|
||||
|
||||
!macro customInstall
|
||||
Call InitializeWindowsMpvShortcutDefaults
|
||||
!insertmacro ResolveWindowsMpvShortcutPaths
|
||||
|
||||
${if} $WindowsMpvShortcutStartMenuEnabled == "1"
|
||||
!ifdef MENU_FILENAME
|
||||
CreateDirectory "$SMPROGRAMS\${MENU_FILENAME}"
|
||||
!endif
|
||||
CreateShortCut "$WindowsMpvShortcutStartMenuPath" "$appExe" "--launch-mpv" "$appExe" 0 "" "" "Launch mpv with the SubMiner profile"
|
||||
ClearErrors
|
||||
WinShell::SetLnkAUMI "$WindowsMpvShortcutStartMenuPath" "${APP_ID}"
|
||||
${else}
|
||||
Delete "$WindowsMpvShortcutStartMenuPath"
|
||||
${endif}
|
||||
|
||||
${if} $WindowsMpvShortcutDesktopEnabled == "1"
|
||||
CreateShortCut "$WindowsMpvShortcutDesktopPath" "$appExe" "--launch-mpv" "$appExe" 0 "" "" "Launch mpv with the SubMiner profile"
|
||||
ClearErrors
|
||||
WinShell::SetLnkAUMI "$WindowsMpvShortcutDesktopPath" "${APP_ID}"
|
||||
${else}
|
||||
Delete "$WindowsMpvShortcutDesktopPath"
|
||||
${endif}
|
||||
|
||||
System::Call 'Shell32::SHChangeNotify(i 0x8000000, i 0, i 0, i 0)'
|
||||
!macroend
|
||||
!endif
|
||||
|
||||
!macro customUnInstall
|
||||
!insertmacro ResolveWindowsMpvShortcutPaths
|
||||
Delete "$WindowsMpvShortcutStartMenuPath"
|
||||
Delete "$WindowsMpvShortcutDesktopPath"
|
||||
!macroend
|
||||
18
build/signpath-windows-artifact-config.xml
Normal file
18
build/signpath-windows-artifact-config.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
|
||||
<zip-file>
|
||||
<pe-file path="SubMiner-*.exe" max-matches="unbounded">
|
||||
<authenticode-sign />
|
||||
</pe-file>
|
||||
<zip-file path="SubMiner-*.zip" max-matches="unbounded">
|
||||
<directory path="*">
|
||||
<pe-file path="*.exe" max-matches="unbounded">
|
||||
<authenticode-sign />
|
||||
</pe-file>
|
||||
<pe-file path="*.dll" max-matches="unbounded">
|
||||
<authenticode-sign />
|
||||
</pe-file>
|
||||
</directory>
|
||||
</zip-file>
|
||||
</zip-file>
|
||||
</artifact-configuration>
|
||||
@@ -2,7 +2,11 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||
import { parsePluginRuntimeConfigContent } from './config/plugin-runtime-config.js';
|
||||
import {
|
||||
getPluginConfigCandidates,
|
||||
parsePluginRuntimeConfigContent,
|
||||
} from './config/plugin-runtime-config.js';
|
||||
import { getDefaultSocketPath } from './types.js';
|
||||
|
||||
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
||||
const parsed = parseLauncherYoutubeSubgenConfig({
|
||||
@@ -97,3 +101,18 @@ auto_start_pause_until_ready = off
|
||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, false);
|
||||
});
|
||||
|
||||
test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
|
||||
assert.deepEqual(
|
||||
getPluginConfigCandidates({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||
}),
|
||||
['C:\\Users\\tester\\AppData\\Roaming\\mpv\\script-opts\\subminer.conf'],
|
||||
);
|
||||
});
|
||||
|
||||
test('getDefaultSocketPath returns Windows named pipe default', () => {
|
||||
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
|
||||
});
|
||||
|
||||
@@ -5,12 +5,29 @@ import { log } from '../log.js';
|
||||
import type { LogLevel, PluginRuntimeConfig } from '../types.js';
|
||||
import { DEFAULT_SOCKET_PATH } from '../types.js';
|
||||
|
||||
export function getPluginConfigCandidates(): string[] {
|
||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
||||
export function getPluginConfigCandidates(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
xdgConfigHome?: string;
|
||||
appDataDir?: string;
|
||||
}): string[] {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const homeDir = options?.homeDir ?? os.homedir();
|
||||
|
||||
if (platform === 'win32') {
|
||||
const appDataDir =
|
||||
options?.appDataDir?.trim() ||
|
||||
process.env.APPDATA?.trim() ||
|
||||
path.join(homeDir, 'AppData', 'Roaming');
|
||||
return [path.join(appDataDir, 'mpv', 'script-opts', 'subminer.conf')];
|
||||
}
|
||||
|
||||
const xdgConfigHome =
|
||||
options?.xdgConfigHome?.trim() || process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config');
|
||||
return Array.from(
|
||||
new Set([
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
path.join(os.homedir(), '.config', 'mpv', 'script-opts', 'subminer.conf'),
|
||||
path.join(homeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
24
launcher/log.test.ts
Normal file
24
launcher/log.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { getDefaultMpvLogFile } from './types.js';
|
||||
|
||||
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
const resolved = getDefaultMpvLogFile({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
path.normalize(resolved),
|
||||
path.normalize(
|
||||
path.join(
|
||||
'C:\\Users\\tester\\AppData\\Roaming',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -7,22 +7,26 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
|
||||
const sequence: Array<SetupState | null> = [
|
||||
null,
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
},
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'skipped',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'skipped',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,23 +54,27 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
||||
if (reads === 1) return null;
|
||||
if (reads === 2) {
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'installed',
|
||||
};
|
||||
},
|
||||
launchSetupApp: () => {
|
||||
@@ -88,13 +96,15 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: () => ({
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
}),
|
||||
launchSetupApp: () => undefined,
|
||||
sleep: async () => undefined,
|
||||
|
||||
@@ -3,7 +3,14 @@ import os from 'node:os';
|
||||
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
||||
|
||||
export const ROFI_THEME_FILE = 'subminer.rasi';
|
||||
export const DEFAULT_SOCKET_PATH = '/tmp/subminer-socket';
|
||||
export function getDefaultSocketPath(platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform === 'win32') {
|
||||
return '\\\\.\\pipe\\subminer-socket';
|
||||
}
|
||||
return '/tmp/subminer-socket';
|
||||
}
|
||||
|
||||
export const DEFAULT_SOCKET_PATH = getDefaultSocketPath();
|
||||
export const DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS = ['ja', 'jpn'];
|
||||
export const DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS = ['en', 'eng'];
|
||||
export const YOUTUBE_SUB_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
|
||||
@@ -22,13 +29,21 @@ export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
|
||||
'subminer',
|
||||
'youtube-subs',
|
||||
);
|
||||
export const DEFAULT_MPV_LOG_FILE = path.join(
|
||||
os.homedir(),
|
||||
'.config',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
);
|
||||
export function getDefaultMpvLogFile(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
appDataDir?: string;
|
||||
}): string {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const homeDir = options?.homeDir ?? os.homedir();
|
||||
const baseDir =
|
||||
platform === 'win32'
|
||||
? path.join(options?.appDataDir?.trim() || path.join(homeDir, 'AppData', 'Roaming'), 'SubMiner')
|
||||
: path.join(homeDir, '.config', 'SubMiner');
|
||||
return path.join(baseDir, 'logs', `SubMiner-${new Date().toISOString().slice(0, 10)}.log`);
|
||||
}
|
||||
|
||||
export const DEFAULT_MPV_LOG_FILE = getDefaultMpvLogFile();
|
||||
export const DEFAULT_YOUTUBE_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
|
||||
export const DEFAULT_JIMAKU_API_BASE_URL = 'https://jimaku.cc';
|
||||
export const DEFAULT_MPV_SUBMINER_ARGS = [
|
||||
|
||||
55
package.json
55
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "subminer",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -11,8 +11,9 @@
|
||||
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
||||
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
|
||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && electron dist/scripts/test-yomitan-parser.js",
|
||||
"build:yomitan": "cd vendor/subminer-yomitan && bun install --frozen-lockfile && bun run build -- --target chrome && rm -rf ../../build/yomitan && mkdir -p ../../build/yomitan && unzip -qo builds/yomitan-chrome.zip -d ../../build/yomitan",
|
||||
"build": "bun run build:yomitan && tsc -p tsconfig.json && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh",
|
||||
"build:yomitan": "node scripts/build-yomitan.mjs",
|
||||
"build:assets": "node scripts/prepare-build-assets.mjs",
|
||||
"build": "bun run build:yomitan && tsc -p tsconfig.json && bun run build:renderer && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"changelog:build": "bun run scripts/build-changelog.ts build",
|
||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||
@@ -26,30 +27,30 @@
|
||||
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts",
|
||||
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js",
|
||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.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/mpv.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/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/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/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.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/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/x11-tracker.test.js",
|
||||
"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/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/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/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/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.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/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/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",
|
||||
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||
"test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts",
|
||||
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
|
||||
"test:immersion:sqlite": "tsc -p tsconfig.json && bun run test:immersion:sqlite:dist",
|
||||
"test:src": "bun scripts/run-test-lane.mjs bun-src-full",
|
||||
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
|
||||
"test:immersion:sqlite:dist": "node --experimental-sqlite --test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
|
||||
"test:immersion:sqlite": "bun run tsc && bun run test:immersion:sqlite:dist",
|
||||
"test:src": "node scripts/run-test-lane.mjs bun-src-full",
|
||||
"test:launcher:unit:src": "node scripts/run-test-lane.mjs bun-launcher-unit",
|
||||
"test:launcher:env:src": "bun run test:launcher:smoke:src && bun run test:plugin:src",
|
||||
"test:env": "bun run test:launcher:env:src && bun run test:immersion:sqlite:src",
|
||||
"test:runtime:compat": "tsc -p tsconfig.json && bun test dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/overlay-manager.test.js dist/main/config-validation.test.js dist/main/runtime/registry.test.js dist/main/runtime/startup-config.test.js",
|
||||
"test:runtime:compat": "bun run tsc && node --experimental-sqlite --test dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/overlay-manager.test.js dist/main/config-validation.test.js dist/main/runtime/registry.test.js dist/main/runtime/startup-config.test.js",
|
||||
"test:node:compat": "bun run test:runtime:compat",
|
||||
"test:full": "bun run test:src && bun run test:launcher:unit:src && bun run test:runtime:compat",
|
||||
"test:full": "bun run test:src && bun run test:launcher:unit:src && bun run test:node:compat",
|
||||
"test": "bun run test:fast",
|
||||
"test:config": "bun run test:config:src",
|
||||
"test:launcher": "bun run test:launcher:src",
|
||||
"test:core": "bun run test:core:src",
|
||||
"test:subtitle": "bun run test:subtitle:src",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts && tsc -p tsconfig.json && bun test dist/main/runtime/registry.test.js",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts && bun run tsc && node --experimental-sqlite --test dist/main/runtime/registry.test.js",
|
||||
"generate:config-example": "bun run build && bun dist/generate-config-example.js",
|
||||
"start": "bun run build && electron . --start",
|
||||
"dev": "bun run build && electron . --start --dev",
|
||||
@@ -58,7 +59,8 @@
|
||||
"build:appimage": "bun run build && electron-builder --linux AppImage",
|
||||
"build:mac": "bun run build && electron-builder --mac dmg zip",
|
||||
"build:mac:unsigned": "bun run build && env -u APPLE_ID -u APPLE_APP_SPECIFIC_PASSWORD -u APPLE_TEAM_ID -u CSC_LINK -u CSC_KEY_PASSWORD CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --mac dmg zip",
|
||||
"build:mac:zip": "bun run build && electron-builder --mac zip"
|
||||
"build:mac:zip": "bun run build && electron-builder --mac zip",
|
||||
"build:win": "bun run build && electron-builder --win nsis zip"
|
||||
},
|
||||
"keywords": [
|
||||
"anki",
|
||||
@@ -116,7 +118,26 @@
|
||||
"icon": "assets/SubMiner.png",
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist"
|
||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "dist/scripts/get-mpv-window-macos",
|
||||
"to": "scripts/get-mpv-window-macos"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"zip"
|
||||
],
|
||||
"icon": "assets/SubMiner.png"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"include": "build/installer.nsh"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
@@ -147,8 +168,8 @@
|
||||
"to": "plugin/subminer.conf"
|
||||
},
|
||||
{
|
||||
"from": "dist/scripts/get-mpv-window-macos",
|
||||
"to": "scripts/get-mpv-window-macos"
|
||||
"from": "dist/scripts/get-mpv-window-windows.ps1",
|
||||
"to": "scripts/get-mpv-window-windows.ps1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
# Path to SubMiner binary (leave empty for auto-detection)
|
||||
# Auto-detection searches common locations, including:
|
||||
# - macOS: /Applications/SubMiner.app/Contents/MacOS/SubMiner, ~/Applications/SubMiner.app/Contents/MacOS/SubMiner
|
||||
# - Windows: %LOCALAPPDATA%\Programs\SubMiner\SubMiner.exe, %ProgramFiles%\SubMiner\SubMiner.exe
|
||||
# - Linux: ~/.local/bin/SubMiner.AppImage, /opt/SubMiner/SubMiner.AppImage, /usr/local/bin/SubMiner, /usr/bin/SubMiner
|
||||
binary_path=
|
||||
|
||||
# Path to mpv IPC socket (must match input-ipc-server in mpv.conf)
|
||||
# Windows installs rewrite this to \\.\pipe\subminer-socket during installation.
|
||||
socket_path=/tmp/subminer-socket
|
||||
|
||||
# Enable texthooker WebSocket server
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
local M = {}
|
||||
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local utils = ctx.utils
|
||||
local opts = ctx.opts
|
||||
local state = ctx.state
|
||||
@@ -26,6 +27,13 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
local function binary_candidates_from_app_path(app_path)
|
||||
if environment.is_windows() then
|
||||
return {
|
||||
utils.join_path(app_path, "SubMiner.exe"),
|
||||
utils.join_path(app_path, "subminer.exe"),
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
utils.join_path(app_path, "Contents", "MacOS", "SubMiner"),
|
||||
utils.join_path(app_path, "Contents", "MacOS", "subminer"),
|
||||
@@ -43,6 +51,11 @@ function M.create(ctx)
|
||||
return true
|
||||
end
|
||||
|
||||
local function directory_exists(path)
|
||||
local info = utils.file_info(path)
|
||||
return info ~= nil and info.is_dir == true
|
||||
end
|
||||
|
||||
local function resolve_binary_candidate(candidate)
|
||||
local normalized = normalize_binary_path_candidate(candidate)
|
||||
if not normalized then
|
||||
@@ -53,6 +66,25 @@ function M.create(ctx)
|
||||
return normalized
|
||||
end
|
||||
|
||||
if environment.is_windows() then
|
||||
if not normalized:lower():match("%.exe$") then
|
||||
local with_exe = normalized .. ".exe"
|
||||
if file_exists(with_exe) then
|
||||
return with_exe
|
||||
end
|
||||
end
|
||||
|
||||
if directory_exists(normalized) then
|
||||
for _, path in ipairs(binary_candidates_from_app_path(normalized)) do
|
||||
if file_exists(path) then
|
||||
return path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
if not normalized:lower():find("%.app") then
|
||||
return nil
|
||||
end
|
||||
@@ -89,6 +121,109 @@ function M.create(ctx)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function add_search_path(search_paths, candidate)
|
||||
if type(candidate) == "string" and candidate ~= "" then
|
||||
search_paths[#search_paths + 1] = candidate
|
||||
end
|
||||
end
|
||||
|
||||
local function trim_subprocess_stdout(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local trimmed = value:match("^%s*(.-)%s*$") or ""
|
||||
if trimmed == "" then
|
||||
return nil
|
||||
end
|
||||
return trimmed
|
||||
end
|
||||
|
||||
local function find_windows_binary_via_system_lookup()
|
||||
if not environment.is_windows() then
|
||||
return nil
|
||||
end
|
||||
if not mp or type(mp.command_native) ~= "function" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local script = [=[
|
||||
function Emit-FirstExistingPath {
|
||||
param([string[]]$Candidates)
|
||||
|
||||
foreach ($candidate in $Candidates) {
|
||||
if ([string]::IsNullOrWhiteSpace($candidate)) {
|
||||
continue
|
||||
}
|
||||
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
|
||||
Write-Output $candidate
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$runningProcess = Get-CimInstance Win32_Process |
|
||||
Where-Object { $_.Name -ieq 'SubMiner.exe' -or $_.Name -ieq 'subminer.exe' } |
|
||||
Select-Object -First 1 -Property ExecutablePath, CommandLine
|
||||
if ($null -ne $runningProcess) {
|
||||
Emit-FirstExistingPath @($runningProcess.ExecutablePath)
|
||||
}
|
||||
|
||||
$localAppData = [Environment]::GetFolderPath('LocalApplicationData')
|
||||
$programFiles = [Environment]::GetFolderPath('ProgramFiles')
|
||||
$programFilesX86 = ${env:ProgramFiles(x86)}
|
||||
|
||||
Emit-FirstExistingPath @(
|
||||
$(if (-not [string]::IsNullOrWhiteSpace($localAppData)) { Join-Path $localAppData 'Programs\SubMiner\SubMiner.exe' } else { $null }),
|
||||
$(if (-not [string]::IsNullOrWhiteSpace($programFiles)) { Join-Path $programFiles 'SubMiner\SubMiner.exe' } else { $null }),
|
||||
$(if (-not [string]::IsNullOrWhiteSpace($programFilesX86)) { Join-Path $programFilesX86 'SubMiner\SubMiner.exe' } else { $null }),
|
||||
'C:\SubMiner\SubMiner.exe'
|
||||
)
|
||||
|
||||
foreach ($registryPath in @(
|
||||
'HKCU:\Software\Microsoft\Windows\CurrentVersion\App Paths\SubMiner.exe',
|
||||
'HKLM:\Software\Microsoft\Windows\CurrentVersion\App Paths\SubMiner.exe',
|
||||
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\SubMiner.exe'
|
||||
)) {
|
||||
try {
|
||||
$appPath = (Get-ItemProperty -Path $registryPath -ErrorAction Stop).'(default)'
|
||||
Emit-FirstExistingPath @($appPath)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$commandPath = Get-Command SubMiner.exe -ErrorAction Stop | Select-Object -First 1 -ExpandProperty Source
|
||||
Emit-FirstExistingPath @($commandPath)
|
||||
} catch {
|
||||
}
|
||||
]=]
|
||||
|
||||
local result = mp.command_native({
|
||||
name = "subprocess",
|
||||
args = {
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script,
|
||||
},
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = false,
|
||||
})
|
||||
if not result or result.status ~= 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local candidate = trim_subprocess_stdout(result.stdout)
|
||||
if not candidate then
|
||||
return nil
|
||||
end
|
||||
|
||||
return resolve_binary_candidate(candidate)
|
||||
end
|
||||
|
||||
local function find_binary()
|
||||
local override = find_binary_override()
|
||||
if override then
|
||||
@@ -100,17 +235,31 @@ function M.create(ctx)
|
||||
return configured
|
||||
end
|
||||
|
||||
local search_paths = {
|
||||
"/Applications/SubMiner.app/Contents/MacOS/SubMiner",
|
||||
utils.join_path(os.getenv("HOME") or "", "Applications/SubMiner.app/Contents/MacOS/SubMiner"),
|
||||
"C:\\Program Files\\SubMiner\\SubMiner.exe",
|
||||
"C:\\Program Files (x86)\\SubMiner\\SubMiner.exe",
|
||||
"C:\\SubMiner\\SubMiner.exe",
|
||||
utils.join_path(os.getenv("HOME") or "", ".local/bin/SubMiner.AppImage"),
|
||||
"/opt/SubMiner/SubMiner.AppImage",
|
||||
"/usr/local/bin/SubMiner",
|
||||
"/usr/bin/SubMiner",
|
||||
}
|
||||
local system_lookup_binary = find_windows_binary_via_system_lookup()
|
||||
if system_lookup_binary then
|
||||
subminer_log("info", "binary", "Found Windows binary via system lookup at: " .. system_lookup_binary)
|
||||
return system_lookup_binary
|
||||
end
|
||||
|
||||
local home = os.getenv("HOME") or os.getenv("USERPROFILE") or ""
|
||||
local app_data = os.getenv("APPDATA") or ""
|
||||
local app_data_local = app_data ~= "" and app_data:gsub("[/\\][Rr][Oo][Aa][Mm][Ii][Nn][Gg]$", "\\Local") or ""
|
||||
local local_app_data = os.getenv("LOCALAPPDATA") or utils.join_path(home, "AppData", "Local")
|
||||
local program_files = os.getenv("ProgramFiles") or "C:\\Program Files"
|
||||
local program_files_x86 = os.getenv("ProgramFiles(x86)") or "C:\\Program Files (x86)"
|
||||
local search_paths = {}
|
||||
|
||||
add_search_path(search_paths, "/Applications/SubMiner.app/Contents/MacOS/SubMiner")
|
||||
add_search_path(search_paths, utils.join_path(home, "Applications", "SubMiner.app", "Contents", "MacOS", "SubMiner"))
|
||||
add_search_path(search_paths, utils.join_path(app_data_local, "Programs", "SubMiner", "SubMiner.exe"))
|
||||
add_search_path(search_paths, utils.join_path(local_app_data, "Programs", "SubMiner", "SubMiner.exe"))
|
||||
add_search_path(search_paths, utils.join_path(program_files, "SubMiner", "SubMiner.exe"))
|
||||
add_search_path(search_paths, utils.join_path(program_files_x86, "SubMiner", "SubMiner.exe"))
|
||||
add_search_path(search_paths, "C:\\SubMiner\\SubMiner.exe")
|
||||
add_search_path(search_paths, utils.join_path(home, ".local", "bin", "SubMiner.AppImage"))
|
||||
add_search_path(search_paths, "/opt/SubMiner/SubMiner.AppImage")
|
||||
add_search_path(search_paths, "/usr/local/bin/SubMiner")
|
||||
add_search_path(search_paths, "/usr/bin/SubMiner")
|
||||
|
||||
for _, path in ipairs(search_paths) do
|
||||
if file_exists(path) then
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
local M = {}
|
||||
local BOOTSTRAP_GUARD_KEY = "__subminer_plugin_bootstrapped"
|
||||
|
||||
function M.init()
|
||||
if rawget(_G, BOOTSTRAP_GUARD_KEY) == true then
|
||||
return
|
||||
end
|
||||
rawset(_G, BOOTSTRAP_GUARD_KEY, true)
|
||||
|
||||
local input = require("mp.input")
|
||||
local mp = require("mp")
|
||||
local msg = require("mp.msg")
|
||||
|
||||
@@ -61,8 +61,9 @@ function M.create(ctx)
|
||||
aniskip.clear_aniskip_state()
|
||||
hover.clear_hover_overlay()
|
||||
process.disarm_auto_play_ready_gate()
|
||||
if state.overlay_running or state.texthooker_running then
|
||||
subminer_log("info", "lifecycle", "mpv shutting down, preserving SubMiner background process")
|
||||
if state.overlay_running then
|
||||
subminer_log("info", "lifecycle", "mpv shutting down, hiding SubMiner overlay")
|
||||
process.hide_visible_overlay()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -75,6 +76,9 @@ function M.create(ctx)
|
||||
mp.register_event("end-file", function()
|
||||
process.disarm_auto_play_ready_gate()
|
||||
hover.clear_hover_overlay()
|
||||
if state.overlay_running then
|
||||
process.hide_visible_overlay()
|
||||
end
|
||||
end)
|
||||
mp.register_event("shutdown", function()
|
||||
hover.clear_hover_overlay()
|
||||
|
||||
@@ -22,4 +22,9 @@ if not package.path:find(module_patterns, 1, true) then
|
||||
package.path = module_patterns .. package.path
|
||||
end
|
||||
|
||||
require("init").init()
|
||||
local init_module = assert(loadfile(script_dir .. "/init.lua"))()
|
||||
if type(init_module) == "table" and type(init_module.init) == "function" then
|
||||
init_module.init()
|
||||
elseif type(init_module) == "function" then
|
||||
init_module()
|
||||
end
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
local M = {}
|
||||
|
||||
local function normalize_socket_path_option(socket_path, default_socket_path)
|
||||
if type(default_socket_path) ~= "string" then
|
||||
return socket_path
|
||||
end
|
||||
|
||||
local trimmed_default = default_socket_path:match("^%s*(.-)%s*$")
|
||||
local trimmed_socket = type(socket_path) == "string" and socket_path:match("^%s*(.-)%s*$") or socket_path
|
||||
if trimmed_default ~= "\\\\.\\pipe\\subminer-socket" then
|
||||
return trimmed_socket
|
||||
end
|
||||
if type(trimmed_socket) ~= "string" or trimmed_socket == "" then
|
||||
return trimmed_default
|
||||
end
|
||||
if trimmed_socket == "/tmp/subminer-socket" or trimmed_socket == "\\tmp\\subminer-socket" then
|
||||
return trimmed_default
|
||||
end
|
||||
if trimmed_socket == "\\\\.\\pipe\\tmp\\subminer-socket" then
|
||||
return trimmed_default
|
||||
end
|
||||
return trimmed_socket
|
||||
end
|
||||
|
||||
function M.load(options_lib, default_socket_path)
|
||||
local opts = {
|
||||
binary_path = "",
|
||||
@@ -25,6 +47,7 @@ function M.load(options_lib, default_socket_path)
|
||||
}
|
||||
|
||||
options_lib.read_options(opts, "subminer")
|
||||
opts.socket_path = normalize_socket_path_option(opts.socket_path, default_socket_path)
|
||||
return opts
|
||||
end
|
||||
|
||||
|
||||
@@ -411,6 +411,28 @@ function M.create(ctx)
|
||||
show_osd("Stopped")
|
||||
end
|
||||
|
||||
local function hide_visible_overlay()
|
||||
if not binary.ensure_binary_available() then
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
return
|
||||
end
|
||||
|
||||
run_control_command_async("hide-visible-overlay", nil, function(ok, result)
|
||||
if ok then
|
||||
subminer_log("info", "process", "Visible overlay hidden")
|
||||
else
|
||||
subminer_log(
|
||||
"warn",
|
||||
"process",
|
||||
"Hide-visible-overlay command returned non-zero status: "
|
||||
.. tostring(result and result.status or "unknown")
|
||||
)
|
||||
end
|
||||
end)
|
||||
|
||||
disarm_auto_play_ready_gate()
|
||||
end
|
||||
|
||||
local function toggle_overlay()
|
||||
if not binary.ensure_binary_available() then
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
@@ -511,6 +533,7 @@ function M.create(ctx)
|
||||
start_overlay = start_overlay,
|
||||
start_overlay_from_script_message = start_overlay_from_script_message,
|
||||
stop_overlay = stop_overlay,
|
||||
hide_visible_overlay = hide_visible_overlay,
|
||||
toggle_overlay = toggle_overlay,
|
||||
open_options = open_options,
|
||||
restart_overlay = restart_overlay,
|
||||
|
||||
170
scripts/build-yomitan.mjs
Normal file
170
scripts/build-yomitan.mjs
Normal file
@@ -0,0 +1,170 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(dirname, '..');
|
||||
const submoduleDir = path.join(repoRoot, 'vendor', 'subminer-yomitan');
|
||||
const submodulePackagePath = path.join(submoduleDir, 'package.json');
|
||||
const submodulePackageLockPath = path.join(submoduleDir, 'package-lock.json');
|
||||
const buildOutputDir = path.join(repoRoot, 'build', 'yomitan');
|
||||
const stampPath = path.join(buildOutputDir, '.subminer-build.json');
|
||||
const zipPath = path.join(submoduleDir, 'builds', 'yomitan-chrome.zip');
|
||||
const npmCommand = process.platform === 'win32' ? process.env.ComSpec ?? 'cmd.exe' : 'npm';
|
||||
const dependencyStampPath = path.join(submoduleDir, 'node_modules', '.subminer-package-lock-hash');
|
||||
|
||||
function run(command, args, cwd) {
|
||||
execFileSync(command, args, { cwd, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
function escapePowerShellString(value) {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function readCommand(command, args, cwd) {
|
||||
return execFileSync(command, args, { cwd, encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
function getNpmArgs(args) {
|
||||
if (process.platform !== 'win32') {
|
||||
return args;
|
||||
}
|
||||
return ['/d', '/s', '/c', 'npm', ...args];
|
||||
}
|
||||
|
||||
function readStamp() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(stampPath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hashFile(filePath) {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(fs.readFileSync(filePath));
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function ensureSubmodulePresent() {
|
||||
if (!fs.existsSync(submodulePackagePath)) {
|
||||
throw new Error(
|
||||
'Missing vendor/subminer-yomitan submodule. Run `git submodule update --init --recursive`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceState() {
|
||||
const revision = readCommand('git', ['rev-parse', 'HEAD'], submoduleDir);
|
||||
const dirty = readCommand('git', ['status', '--short', '--untracked-files=no'], submoduleDir);
|
||||
return { revision, dirty };
|
||||
}
|
||||
|
||||
function isBuildCurrent(force) {
|
||||
if (force) {
|
||||
return false;
|
||||
}
|
||||
if (!fs.existsSync(path.join(buildOutputDir, 'manifest.json'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stamp = readStamp();
|
||||
if (!stamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentState = getSourceState();
|
||||
return stamp.revision === currentState.revision && stamp.dirty === currentState.dirty;
|
||||
}
|
||||
|
||||
function ensureDependenciesInstalled() {
|
||||
const nodeModulesDir = path.join(submoduleDir, 'node_modules');
|
||||
const currentLockHash = hashFile(submodulePackageLockPath);
|
||||
let installedLockHash = '';
|
||||
try {
|
||||
installedLockHash = fs.readFileSync(dependencyStampPath, 'utf8').trim();
|
||||
} catch {}
|
||||
|
||||
if (!fs.existsSync(nodeModulesDir) || installedLockHash !== currentLockHash) {
|
||||
run(npmCommand, getNpmArgs(['ci']), submoduleDir);
|
||||
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
||||
fs.writeFileSync(dependencyStampPath, `${currentLockHash}\n`, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function installAndBuild() {
|
||||
ensureDependenciesInstalled();
|
||||
run(npmCommand, getNpmArgs(['run', 'build', '--', '--target', 'chrome']), submoduleDir);
|
||||
}
|
||||
|
||||
function extractBuild() {
|
||||
if (!fs.existsSync(zipPath)) {
|
||||
throw new Error(`Expected Yomitan build artifact at ${zipPath}`);
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yomitan-'));
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
run(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
`Expand-Archive -LiteralPath '${escapePowerShellString(zipPath)}' -DestinationPath '${escapePowerShellString(tempDir)}' -Force`,
|
||||
],
|
||||
repoRoot,
|
||||
);
|
||||
} else {
|
||||
run('unzip', ['-qo', zipPath, '-d', tempDir], repoRoot);
|
||||
}
|
||||
fs.rmSync(buildOutputDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(buildOutputDir), { recursive: true });
|
||||
fs.cpSync(tempDir, buildOutputDir, { recursive: true });
|
||||
if (!fs.existsSync(path.join(buildOutputDir, 'manifest.json'))) {
|
||||
throw new Error(`Extracted Yomitan build missing manifest.json in ${buildOutputDir}`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeStamp() {
|
||||
const state = getSourceState();
|
||||
fs.writeFileSync(
|
||||
stampPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
revision: state.revision,
|
||||
dirty: state.dirty,
|
||||
builtAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const force = process.argv.includes('--force');
|
||||
ensureSubmodulePresent();
|
||||
|
||||
if (isBuildCurrent(force)) {
|
||||
process.stdout.write(`Yomitan build current: ${buildOutputDir}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write('Building Yomitan Chrome artifact...\n');
|
||||
installAndBuild();
|
||||
extractBuild();
|
||||
writeStamp();
|
||||
process.stdout.write(`Yomitan extracted to ${buildOutputDir}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
101
scripts/configure-plugin-binary-path.mjs
Normal file
101
scripts/configure-plugin-binary-path.mjs
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function normalizeCandidate(candidate) {
|
||||
if (typeof candidate !== 'string') return '';
|
||||
const trimmed = candidate.trim();
|
||||
return trimmed.length > 0 ? trimmed : '';
|
||||
}
|
||||
|
||||
function fileExists(candidate) {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return Array.from(new Set(values.filter((value) => value.length > 0)));
|
||||
}
|
||||
|
||||
function findWindowsBinary(repoRoot) {
|
||||
const homeDir = process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || '';
|
||||
const appDataDir = process.env.APPDATA?.trim() || '';
|
||||
const derivedLocalAppData =
|
||||
appDataDir && /[\\/]Roaming$/i.test(appDataDir)
|
||||
? appDataDir.replace(/[\\/]Roaming$/i, `${path.sep}Local`)
|
||||
: '';
|
||||
const localAppData =
|
||||
process.env.LOCALAPPDATA?.trim() ||
|
||||
derivedLocalAppData ||
|
||||
(homeDir ? path.join(homeDir, 'AppData', 'Local') : '');
|
||||
const programFiles = process.env.ProgramFiles?.trim() || 'C:\\Program Files';
|
||||
const programFilesX86 = process.env['ProgramFiles(x86)']?.trim() || 'C:\\Program Files (x86)';
|
||||
|
||||
const candidates = unique([
|
||||
normalizeCandidate(process.env.SUBMINER_BINARY_PATH),
|
||||
normalizeCandidate(process.env.SUBMINER_APPIMAGE_PATH),
|
||||
localAppData ? path.join(localAppData, 'Programs', 'SubMiner', 'SubMiner.exe') : '',
|
||||
path.join(programFiles, 'SubMiner', 'SubMiner.exe'),
|
||||
path.join(programFilesX86, 'SubMiner', 'SubMiner.exe'),
|
||||
'C:\\SubMiner\\SubMiner.exe',
|
||||
path.join(repoRoot, 'release', 'win-unpacked', 'SubMiner.exe'),
|
||||
path.join(repoRoot, 'release', 'SubMiner', 'SubMiner.exe'),
|
||||
path.join(repoRoot, 'release', 'SubMiner.exe'),
|
||||
]);
|
||||
|
||||
return candidates.find((candidate) => fileExists(candidate)) || '';
|
||||
}
|
||||
|
||||
function rewriteBinaryPath(configPath, binaryPath) {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const normalizedPath = binaryPath.replace(/\r?\n/g, ' ').trim();
|
||||
const updated = content.replace(/^binary_path=.*$/m, `binary_path=${normalizedPath}`);
|
||||
if (updated !== content) {
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteSocketPath(configPath, socketPath) {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const normalizedPath = socketPath.replace(/\r?\n/g, ' ').trim();
|
||||
const updated = content.replace(/^socket_path=.*$/m, `socket_path=${normalizedPath}`);
|
||||
if (updated !== content) {
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
const [, , configPathArg, repoRootArg, platformArg] = process.argv;
|
||||
const configPath = normalizeCandidate(configPathArg);
|
||||
const repoRoot = normalizeCandidate(repoRootArg) || process.cwd();
|
||||
const platform = normalizeCandidate(platformArg) || process.platform;
|
||||
|
||||
if (!configPath) {
|
||||
console.error('[ERROR] Missing plugin config path');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fileExists(configPath)) {
|
||||
console.error(`[ERROR] Plugin config not found: ${configPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (platform !== 'win32') {
|
||||
console.log('[INFO] Skipping binary_path rewrite for non-Windows platform');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const windowsSocketPath = '\\\\.\\pipe\\subminer-socket';
|
||||
rewriteSocketPath(configPath, windowsSocketPath);
|
||||
|
||||
const binaryPath = findWindowsBinary(repoRoot);
|
||||
if (!binaryPath) {
|
||||
console.warn(
|
||||
`[WARN] Configured plugin socket_path=${windowsSocketPath} but could not detect SubMiner.exe; set binary_path manually or provide SUBMINER_BINARY_PATH`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
rewriteBinaryPath(configPath, binaryPath);
|
||||
console.log(`[INFO] Configured plugin socket_path=${windowsSocketPath} binary_path=${binaryPath}`);
|
||||
175
scripts/get-mpv-window-windows.ps1
Normal file
175
scripts/get-mpv-window-windows.ps1
Normal file
@@ -0,0 +1,175 @@
|
||||
param(
|
||||
[ValidateSet('geometry')]
|
||||
[string]$Mode = 'geometry',
|
||||
[string]$SocketPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
try {
|
||||
Add-Type -TypeDefinition @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class SubMinerWindowsHelper {
|
||||
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT {
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool IsWindowVisible(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool IsIconic(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
||||
|
||||
[DllImport("dwmapi.dll")]
|
||||
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
|
||||
}
|
||||
"@
|
||||
|
||||
$DWMWA_EXTENDED_FRAME_BOUNDS = 9
|
||||
|
||||
function Get-WindowBounds {
|
||||
param([IntPtr]$hWnd)
|
||||
|
||||
$rect = New-Object SubMinerWindowsHelper+RECT
|
||||
$size = [System.Runtime.InteropServices.Marshal]::SizeOf($rect)
|
||||
$dwmResult = [SubMinerWindowsHelper]::DwmGetWindowAttribute(
|
||||
$hWnd,
|
||||
$DWMWA_EXTENDED_FRAME_BOUNDS,
|
||||
[ref]$rect,
|
||||
$size
|
||||
)
|
||||
|
||||
if ($dwmResult -ne 0) {
|
||||
if (-not [SubMinerWindowsHelper]::GetWindowRect($hWnd, [ref]$rect)) {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
$width = $rect.Right - $rect.Left
|
||||
$height = $rect.Bottom - $rect.Top
|
||||
if ($width -le 0 -or $height -le 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
X = $rect.Left
|
||||
Y = $rect.Top
|
||||
Width = $width
|
||||
Height = $height
|
||||
Area = $width * $height
|
||||
}
|
||||
}
|
||||
|
||||
$commandLineByPid = @{}
|
||||
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
|
||||
foreach ($process in Get-CimInstance Win32_Process) {
|
||||
$commandLineByPid[[uint32]$process.ProcessId] = $process.CommandLine
|
||||
}
|
||||
}
|
||||
|
||||
$matches = New-Object System.Collections.Generic.List[object]
|
||||
$foregroundWindow = [SubMinerWindowsHelper]::GetForegroundWindow()
|
||||
$callback = [SubMinerWindowsHelper+EnumWindowsProc]{
|
||||
param([IntPtr]$hWnd, [IntPtr]$lParam)
|
||||
|
||||
if (-not [SubMinerWindowsHelper]::IsWindowVisible($hWnd)) {
|
||||
return $true
|
||||
}
|
||||
|
||||
if ([SubMinerWindowsHelper]::IsIconic($hWnd)) {
|
||||
return $true
|
||||
}
|
||||
|
||||
[uint32]$windowProcessId = 0
|
||||
[void][SubMinerWindowsHelper]::GetWindowThreadProcessId($hWnd, [ref]$windowProcessId)
|
||||
if ($windowProcessId -eq 0) {
|
||||
return $true
|
||||
}
|
||||
|
||||
try {
|
||||
$process = Get-Process -Id $windowProcessId -ErrorAction Stop
|
||||
} catch {
|
||||
return $true
|
||||
}
|
||||
|
||||
if ($process.ProcessName -ine 'mpv') {
|
||||
return $true
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
|
||||
$commandLine = $commandLineByPid[[uint32]$windowProcessId]
|
||||
if ([string]::IsNullOrWhiteSpace($commandLine)) {
|
||||
return $true
|
||||
}
|
||||
if (
|
||||
($commandLine -notlike "*--input-ipc-server=$SocketPath*") -and
|
||||
($commandLine -notlike "*--input-ipc-server $SocketPath*")
|
||||
) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
$bounds = Get-WindowBounds -hWnd $hWnd
|
||||
if ($null -eq $bounds) {
|
||||
return $true
|
||||
}
|
||||
|
||||
$matches.Add([PSCustomObject]@{
|
||||
HWnd = $hWnd
|
||||
X = $bounds.X
|
||||
Y = $bounds.Y
|
||||
Width = $bounds.Width
|
||||
Height = $bounds.Height
|
||||
Area = $bounds.Area
|
||||
IsForeground = ($foregroundWindow -ne [IntPtr]::Zero -and $hWnd -eq $foregroundWindow)
|
||||
})
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
[void][SubMinerWindowsHelper]::EnumWindows($callback, [IntPtr]::Zero)
|
||||
|
||||
$focusedMatch = $matches | Where-Object { $_.IsForeground } | Select-Object -First 1
|
||||
if ($null -ne $focusedMatch) {
|
||||
[Console]::Error.WriteLine('focus=focused')
|
||||
} else {
|
||||
[Console]::Error.WriteLine('focus=not-focused')
|
||||
}
|
||||
|
||||
if ($matches.Count -eq 0) {
|
||||
Write-Output 'not-found'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$bestMatch = if ($null -ne $focusedMatch) {
|
||||
$focusedMatch
|
||||
} else {
|
||||
$matches | Sort-Object -Property Area, Width, Height -Descending | Select-Object -First 1
|
||||
}
|
||||
Write-Output "$($bestMatch.X),$($bestMatch.Y),$($bestMatch.Width),$($bestMatch.Height)"
|
||||
} catch {
|
||||
[Console]::Error.WriteLine($_.Exception.Message)
|
||||
exit 1
|
||||
}
|
||||
84
scripts/prepare-build-assets.mjs
Normal file
84
scripts/prepare-build-assets.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
||||
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
||||
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
||||
const windowsHelperSourcePath = path.join(scriptDir, 'get-mpv-window-windows.ps1');
|
||||
const windowsHelperOutputPath = path.join(scriptsOutputDir, 'get-mpv-window-windows.ps1');
|
||||
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
||||
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
||||
const macosHelperSourceCopyPath = path.join(scriptsOutputDir, 'get-mpv-window-macos.swift');
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function copyFile(sourcePath, outputPath) {
|
||||
ensureDir(path.dirname(outputPath));
|
||||
fs.copyFileSync(sourcePath, outputPath);
|
||||
}
|
||||
|
||||
function copyRendererAssets() {
|
||||
copyFile(path.join(rendererSourceDir, 'index.html'), path.join(rendererOutputDir, 'index.html'));
|
||||
copyFile(path.join(rendererSourceDir, 'style.css'), path.join(rendererOutputDir, 'style.css'));
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(rendererOutputDir, 'fonts'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
process.stdout.write(`Staged renderer assets in ${rendererOutputDir}\n`);
|
||||
}
|
||||
|
||||
function stageWindowsHelper() {
|
||||
copyFile(windowsHelperSourcePath, windowsHelperOutputPath);
|
||||
process.stdout.write(`Staged Windows helper: ${windowsHelperOutputPath}\n`);
|
||||
}
|
||||
|
||||
function fallbackToMacosSource() {
|
||||
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
|
||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
||||
}
|
||||
|
||||
function shouldSkipMacosHelperBuild() {
|
||||
return process.env.SUBMINER_SKIP_MACOS_HELPER_BUILD === '1';
|
||||
}
|
||||
|
||||
function buildMacosHelper() {
|
||||
if (shouldSkipMacosHelperBuild()) {
|
||||
process.stdout.write('Skipping macOS helper build (SUBMINER_SKIP_MACOS_HELPER_BUILD=1)\n');
|
||||
fallbackToMacosSource();
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.stdout.write('Skipping macOS helper build (not on macOS)\n');
|
||||
fallbackToMacosSource();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('swiftc', ['-O', macosHelperSourcePath, '-o', macosHelperBinaryPath], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
fs.chmodSync(macosHelperBinaryPath, 0o755);
|
||||
process.stdout.write(`Built macOS helper: ${macosHelperBinaryPath}\n`);
|
||||
} catch (error) {
|
||||
process.stdout.write('Failed to compile macOS helper; using source fallback.\n');
|
||||
fallbackToMacosSource();
|
||||
if (error instanceof Error) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
copyRendererAssets();
|
||||
stageWindowsHelper();
|
||||
buildMacosHelper();
|
||||
}
|
||||
|
||||
main();
|
||||
223
scripts/test-plugin-binary-windows.lua
Normal file
223
scripts/test-plugin-binary-windows.lua
Normal file
@@ -0,0 +1,223 @@
|
||||
local function assert_equal(actual, expected, message)
|
||||
if actual == expected then
|
||||
return
|
||||
end
|
||||
error((message or "assert_equal failed") .. "\nexpected: " .. tostring(expected) .. "\nactual: " .. tostring(actual))
|
||||
end
|
||||
|
||||
local function assert_true(condition, message)
|
||||
if condition then
|
||||
return
|
||||
end
|
||||
error(message or "assert_true failed")
|
||||
end
|
||||
|
||||
local function with_env(env, callback)
|
||||
local original_getenv = os.getenv
|
||||
os.getenv = function(name)
|
||||
local value = env[name]
|
||||
if value ~= nil then
|
||||
return value
|
||||
end
|
||||
return original_getenv(name)
|
||||
end
|
||||
|
||||
local ok, result = pcall(callback)
|
||||
os.getenv = original_getenv
|
||||
if not ok then
|
||||
error(result)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function create_binary_module(config)
|
||||
local binary_module = dofile("plugin/subminer/binary.lua")
|
||||
local entries = config.entries or {}
|
||||
|
||||
local binary = binary_module.create({
|
||||
mp = config.mp,
|
||||
utils = {
|
||||
file_info = function(path)
|
||||
local entry = entries[path]
|
||||
if entry == "file" then
|
||||
return { is_dir = false }
|
||||
end
|
||||
if entry == "dir" then
|
||||
return { is_dir = true }
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
join_path = function(...)
|
||||
return table.concat({ ... }, "\\")
|
||||
end,
|
||||
},
|
||||
opts = {
|
||||
binary_path = config.binary_path or "",
|
||||
},
|
||||
state = {},
|
||||
environment = {
|
||||
is_windows = function()
|
||||
return config.is_windows == true
|
||||
end,
|
||||
},
|
||||
log = {
|
||||
subminer_log = function() end,
|
||||
},
|
||||
})
|
||||
|
||||
return binary
|
||||
end
|
||||
|
||||
do
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
binary_path = "C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner",
|
||||
entries = {
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
|
||||
assert_equal(
|
||||
binary.find_binary(),
|
||||
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
|
||||
"windows resolver should append .exe for configured binary_path"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
mp = {
|
||||
command_native = function(command)
|
||||
local args = command.args or {}
|
||||
if args[1] == "powershell.exe" then
|
||||
return {
|
||||
status = 0,
|
||||
stdout = "C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe\n",
|
||||
stderr = "",
|
||||
}
|
||||
end
|
||||
return {
|
||||
status = 1,
|
||||
stdout = "",
|
||||
stderr = "unexpected command",
|
||||
}
|
||||
end,
|
||||
},
|
||||
entries = {
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
|
||||
assert_equal(
|
||||
binary.find_binary(),
|
||||
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
|
||||
"windows resolver should recover binary from running SubMiner process"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
binary_path = "C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner",
|
||||
entries = {
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner"] = "dir",
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
|
||||
assert_equal(
|
||||
binary.find_binary(),
|
||||
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
|
||||
"windows resolver should accept install directory binary_path"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local resolved = with_env({
|
||||
LOCALAPPDATA = "C:\\Users\\tester\\AppData\\Local",
|
||||
HOME = "",
|
||||
USERPROFILE = "C:\\Users\\tester",
|
||||
ProgramFiles = "C:\\Program Files",
|
||||
["ProgramFiles(x86)"] = "C:\\Program Files (x86)",
|
||||
}, function()
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
entries = {
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
return binary.find_binary()
|
||||
end)
|
||||
|
||||
assert_equal(
|
||||
resolved,
|
||||
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
|
||||
"windows auto-detection should probe LOCALAPPDATA install path"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local resolved = with_env({
|
||||
APPDATA = "C:\\Users\\tester\\AppData\\Roaming",
|
||||
LOCALAPPDATA = "",
|
||||
HOME = "",
|
||||
USERPROFILE = "C:\\Users\\tester",
|
||||
ProgramFiles = "C:\\Program Files",
|
||||
["ProgramFiles(x86)"] = "C:\\Program Files (x86)",
|
||||
}, function()
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
entries = {
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
return binary.find_binary()
|
||||
end)
|
||||
|
||||
assert_equal(
|
||||
resolved,
|
||||
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
|
||||
"windows auto-detection should derive Local install path from APPDATA"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local resolved = with_env({
|
||||
SUBMINER_BINARY_PATH = "C:\\Portable\\SubMiner\\SubMiner",
|
||||
}, function()
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
entries = {
|
||||
["C:\\Portable\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
return binary.find_binary()
|
||||
end)
|
||||
|
||||
assert_equal(
|
||||
resolved,
|
||||
"C:\\Portable\\SubMiner\\SubMiner.exe",
|
||||
"windows env override should resolve .exe suffix"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
binary_path = "C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner",
|
||||
entries = {
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner"] = "dir",
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
|
||||
assert_true(binary.ensure_binary_available() == true, "ensure_binary_available should cache discovered windows binary")
|
||||
assert_equal(
|
||||
binary.find_binary(),
|
||||
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
|
||||
"ensure_binary_available should not break follow-up lookup"
|
||||
)
|
||||
end
|
||||
|
||||
print("plugin windows binary resolver tests: OK")
|
||||
@@ -802,4 +802,29 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
platform = "windows",
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = "C:/Users/test/AppData/Local/Programs/SubMiner/SubMiner.exe",
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "\\\\.\\pipe\\subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
["C:/Users/test/AppData/Local/Programs/SubMiner/SubMiner.exe"] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for Windows legacy socket config scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
local start_call = find_start_call(recorded.async_calls)
|
||||
assert_true(
|
||||
start_call ~= nil,
|
||||
"Windows plugin should normalize legacy /tmp socket_path values to the named pipe default"
|
||||
)
|
||||
end
|
||||
|
||||
print("plugin start gate regression tests: OK")
|
||||
|
||||
@@ -47,6 +47,14 @@ test('parseArgs ignores missing value after --log-level', () => {
|
||||
assert.equal(args.start, true);
|
||||
});
|
||||
|
||||
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);
|
||||
assert.deepEqual(args.launchMpvTargets, ['C:\\a.mkv', 'C:\\b.mkv']);
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), false);
|
||||
});
|
||||
|
||||
test('parseArgs handles jellyfin item listing controls', () => {
|
||||
const args = parseArgs([
|
||||
'--jellyfin-items',
|
||||
@@ -76,6 +84,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(stopOnly), true);
|
||||
assert.equal(shouldStartApp(stopOnly), false);
|
||||
|
||||
const launchMpv = parseArgs(['--launch-mpv']);
|
||||
assert.equal(launchMpv.launchMpv, true);
|
||||
assert.deepEqual(launchMpv.launchMpvTargets, []);
|
||||
assert.equal(hasExplicitCommand(launchMpv), true);
|
||||
assert.equal(shouldStartApp(launchMpv), false);
|
||||
|
||||
const toggle = parseArgs(['--toggle-visible-overlay']);
|
||||
assert.equal(hasExplicitCommand(toggle), true);
|
||||
assert.equal(shouldStartApp(toggle), true);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface CliArgs {
|
||||
background: boolean;
|
||||
start: boolean;
|
||||
launchMpv: boolean;
|
||||
launchMpvTargets: string[];
|
||||
stop: boolean;
|
||||
toggle: boolean;
|
||||
toggleVisibleOverlay: boolean;
|
||||
@@ -68,6 +70,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
background: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
@@ -123,6 +127,11 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
|
||||
if (arg === '--background') args.background = true;
|
||||
else if (arg === '--start') args.start = true;
|
||||
else if (arg === '--launch-mpv') {
|
||||
args.launchMpv = true;
|
||||
args.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--'));
|
||||
break;
|
||||
}
|
||||
else if (arg === '--stop') args.stop = true;
|
||||
else if (arg === '--toggle') args.toggle = true;
|
||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||
@@ -297,6 +306,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
return (
|
||||
args.background ||
|
||||
args.start ||
|
||||
args.launchMpv ||
|
||||
args.stop ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
@@ -342,6 +352,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
if (
|
||||
args.background ||
|
||||
args.start ||
|
||||
args.launchMpv ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.settings ||
|
||||
@@ -361,6 +372,9 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.jellyfinPlay ||
|
||||
args.texthooker
|
||||
) {
|
||||
if (args.launchMpv) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -17,6 +17,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
|
||||
assert.match(output, /--help\s+Show this help/);
|
||||
assert.match(output, /default: 7777/);
|
||||
assert.match(output, /--launch-mpv/);
|
||||
assert.match(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
|
||||
@@ -12,6 +12,7 @@ ${B}Usage:${R} subminer ${D}[command] [options]${R}
|
||||
${B}Session${R}
|
||||
--background Start in tray/background mode
|
||||
--start Connect to mpv and launch overlay
|
||||
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
|
||||
--stop Stop the running instance
|
||||
--texthooker Start texthooker server only ${D}(no overlay)${R}
|
||||
|
||||
@@ -68,7 +69,7 @@ ${B}Jellyfin${R}
|
||||
|
||||
${B}Options${R}
|
||||
--socket ${D}PATH${R} mpv IPC socket path
|
||||
--backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos)${R}
|
||||
--backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos, windows)${R}
|
||||
--port ${D}PORT${R} Texthooker server port ${D}(default: ${defaultTexthookerPort})${R}
|
||||
--log-level ${D}LEVEL${R} ${D}debug | info | warn | error${R}
|
||||
--debug Enable debug mode ${D}(alias: --dev)${R}
|
||||
|
||||
@@ -10,8 +10,20 @@ function existsSyncFrom(paths: string[]): (candidate: string) => boolean {
|
||||
|
||||
test('resolveConfigBaseDirs trims xdg value and deduplicates fallback dir', () => {
|
||||
const homeDir = '/home/tester';
|
||||
const baseDirs = resolveConfigBaseDirs(' /home/tester/.config ', homeDir);
|
||||
assert.deepEqual(baseDirs, [path.join(homeDir, '.config')]);
|
||||
const trimmedXdgConfigHome = '/home/tester/.config';
|
||||
const fallbackDir = path.join(homeDir, '.config');
|
||||
const baseDirs = resolveConfigBaseDirs(` ${trimmedXdgConfigHome} `, homeDir, 'linux');
|
||||
const expected = Array.from(new Set([trimmedXdgConfigHome, fallbackDir]));
|
||||
assert.deepEqual(baseDirs, expected);
|
||||
});
|
||||
|
||||
test('resolveConfigBaseDirs prefers APPDATA on windows and deduplicates fallback dir', () => {
|
||||
const homeDir = 'C:\\Users\\tester';
|
||||
const appDataDir = 'C:\\Users\\tester\\AppData\\Roaming';
|
||||
|
||||
const baseDirs = resolveConfigBaseDirs(undefined, homeDir, 'win32', ` ${appDataDir} `);
|
||||
|
||||
assert.deepEqual(baseDirs, [appDataDir]);
|
||||
});
|
||||
|
||||
test('resolveConfigDir prefers xdg SubMiner config when present', () => {
|
||||
@@ -23,6 +35,7 @@ test('resolveConfigDir prefers xdg SubMiner config when present', () => {
|
||||
const resolved = resolveConfigDir({
|
||||
xdgConfigHome,
|
||||
homeDir,
|
||||
platform: 'linux',
|
||||
existsSync,
|
||||
});
|
||||
|
||||
@@ -37,10 +50,11 @@ test('resolveConfigDir ignores lowercase subminer candidate', () => {
|
||||
const resolved = resolveConfigDir({
|
||||
xdgConfigHome: '/tmp/missing-xdg',
|
||||
homeDir,
|
||||
platform: 'linux',
|
||||
existsSync,
|
||||
});
|
||||
|
||||
assert.equal(resolved, '/tmp/missing-xdg/SubMiner');
|
||||
assert.equal(resolved, path.join('/tmp/missing-xdg', 'SubMiner'));
|
||||
});
|
||||
|
||||
test('resolveConfigDir falls back to existing directory when file is missing', () => {
|
||||
@@ -51,6 +65,7 @@ test('resolveConfigDir falls back to existing directory when file is missing', (
|
||||
const resolved = resolveConfigDir({
|
||||
xdgConfigHome: '/tmp/missing-xdg',
|
||||
homeDir,
|
||||
platform: 'linux',
|
||||
existsSync,
|
||||
});
|
||||
|
||||
@@ -68,6 +83,7 @@ test('resolveConfigFilePath prefers jsonc before json', () => {
|
||||
const resolved = resolveConfigFilePath({
|
||||
xdgConfigHome,
|
||||
homeDir,
|
||||
platform: 'linux',
|
||||
existsSync,
|
||||
});
|
||||
|
||||
@@ -82,8 +98,40 @@ test('resolveConfigFilePath keeps legacy fallback output path', () => {
|
||||
const resolved = resolveConfigFilePath({
|
||||
xdgConfigHome,
|
||||
homeDir,
|
||||
platform: 'linux',
|
||||
existsSync,
|
||||
});
|
||||
|
||||
assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
|
||||
});
|
||||
|
||||
test('resolveConfigDir prefers APPDATA SubMiner config on windows when present', () => {
|
||||
const homeDir = 'C:\\Users\\tester';
|
||||
const appDataDir = 'C:\\Users\\tester\\AppData\\Roaming';
|
||||
const configDir = path.join(appDataDir, 'SubMiner');
|
||||
const existsSync = existsSyncFrom([path.join(configDir, 'config.jsonc')]);
|
||||
|
||||
const resolved = resolveConfigDir({
|
||||
platform: 'win32',
|
||||
appDataDir,
|
||||
homeDir,
|
||||
existsSync,
|
||||
});
|
||||
|
||||
assert.equal(resolved, configDir);
|
||||
});
|
||||
|
||||
test('resolveConfigFilePath uses APPDATA fallback output path on windows', () => {
|
||||
const homeDir = 'C:\\Users\\tester';
|
||||
const appDataDir = 'C:\\Users\\tester\\AppData\\Roaming';
|
||||
const existsSync = existsSyncFrom([]);
|
||||
|
||||
const resolved = resolveConfigFilePath({
|
||||
platform: 'win32',
|
||||
appDataDir,
|
||||
homeDir,
|
||||
existsSync,
|
||||
});
|
||||
|
||||
assert.equal(resolved, path.join(appDataDir, 'SubMiner', 'config.jsonc'));
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import path from 'node:path';
|
||||
type ExistsSync = (candidate: string) => boolean;
|
||||
|
||||
type ConfigPathOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
appDataDir?: string;
|
||||
xdgConfigHome?: string;
|
||||
homeDir: string;
|
||||
existsSync: ExistsSync;
|
||||
@@ -16,7 +18,15 @@ const DEFAULT_FILE_NAMES = ['config.jsonc', 'config.json'] as const;
|
||||
export function resolveConfigBaseDirs(
|
||||
xdgConfigHome: string | undefined,
|
||||
homeDir: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
appDataDir?: string,
|
||||
): string[] {
|
||||
if (platform === 'win32') {
|
||||
const roamingBaseDir = path.join(homeDir, 'AppData', 'Roaming');
|
||||
const primaryBaseDir = appDataDir?.trim() || roamingBaseDir;
|
||||
return Array.from(new Set([primaryBaseDir, roamingBaseDir]));
|
||||
}
|
||||
|
||||
const fallbackBaseDir = path.join(homeDir, '.config');
|
||||
const primaryBaseDir = xdgConfigHome?.trim() || fallbackBaseDir;
|
||||
return Array.from(new Set([primaryBaseDir, fallbackBaseDir]));
|
||||
@@ -31,7 +41,12 @@ function getDefaultAppName(options: ConfigPathOptions): string {
|
||||
}
|
||||
|
||||
export function resolveConfigDir(options: ConfigPathOptions): string {
|
||||
const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir);
|
||||
const baseDirs = resolveConfigBaseDirs(
|
||||
options.xdgConfigHome,
|
||||
options.homeDir,
|
||||
options.platform,
|
||||
options.appDataDir,
|
||||
);
|
||||
const appNames = getAppNames(options);
|
||||
|
||||
for (const baseDir of baseDirs) {
|
||||
@@ -58,7 +73,12 @@ export function resolveConfigDir(options: ConfigPathOptions): string {
|
||||
}
|
||||
|
||||
export function resolveConfigFilePath(options: ConfigPathOptions): string {
|
||||
const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir);
|
||||
const baseDirs = resolveConfigBaseDirs(
|
||||
options.xdgConfigHome,
|
||||
options.homeDir,
|
||||
options.platform,
|
||||
options.appDataDir,
|
||||
);
|
||||
const appNames = getAppNames(options);
|
||||
|
||||
for (const baseDir of baseDirs) {
|
||||
|
||||
@@ -112,7 +112,7 @@ export function generateConfigTemplate(
|
||||
lines.push(' *');
|
||||
lines.push(' * This file is auto-generated from src/config/definitions.ts.');
|
||||
lines.push(
|
||||
' * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.',
|
||||
' * Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS.',
|
||||
);
|
||||
lines.push(' */');
|
||||
lines.push('{');
|
||||
|
||||
@@ -7,6 +7,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
|
||||
@@ -7,6 +7,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
|
||||
@@ -90,6 +90,9 @@ export function initializeOverlayRuntime(options: {
|
||||
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
};
|
||||
windowTracker.onTargetWindowFocusChange = () => {
|
||||
options.syncOverlayShortcuts();
|
||||
};
|
||||
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
if (options.isVisibleOverlayVisible()) {
|
||||
|
||||
@@ -21,8 +21,8 @@ function createMainWindowRecorder() {
|
||||
focus: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
setIgnoreMouseEvents: () => {
|
||||
calls.push('mouse-ignore');
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -122,6 +122,85 @@ test('non-macOS keeps fallback visible overlay behavior when tracker is not read
|
||||
assert.ok(!calls.includes('osd'));
|
||||
});
|
||||
|
||||
test('Windows visible overlay stays click-through and does not steal focus while tracked', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
test('Windows keeps visible overlay hidden while tracker is not ready', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => false,
|
||||
getGeometry: () => null,
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
},
|
||||
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: false,
|
||||
isWindowsPlatform: true,
|
||||
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||
} as never);
|
||||
|
||||
assert.equal(trackerWarning, true);
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('update-bounds'));
|
||||
});
|
||||
|
||||
test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
|
||||
@@ -14,6 +14,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
isMacOSPlatform?: boolean;
|
||||
isWindowsPlatform?: boolean;
|
||||
showOverlayLoadingOsd?: (message: string) => void;
|
||||
resolveFallbackBounds?: () => WindowGeometry;
|
||||
}): void {
|
||||
@@ -21,9 +22,24 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainWindow = args.mainWindow;
|
||||
|
||||
const showPassiveVisibleOverlay = (): void => {
|
||||
if (args.isWindowsPlatform) {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
}
|
||||
args.ensureOverlayWindowLevel(mainWindow);
|
||||
mainWindow.show();
|
||||
if (!args.isWindowsPlatform) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
};
|
||||
|
||||
if (!args.visibleOverlayVisible) {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.mainWindow.hide();
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
@@ -35,31 +51,27 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
}
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
args.mainWindow.setIgnoreMouseEvents(false);
|
||||
args.ensureOverlayWindowLevel(args.mainWindow);
|
||||
args.mainWindow.show();
|
||||
args.mainWindow.focus();
|
||||
showPassiveVisibleOverlay();
|
||||
args.enforceOverlayLayerOrder();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.windowTracker) {
|
||||
if (args.isMacOSPlatform) {
|
||||
if (args.isMacOSPlatform || args.isWindowsPlatform) {
|
||||
if (!args.trackerNotReadyWarningShown) {
|
||||
args.setTrackerNotReadyWarningShown(true);
|
||||
args.showOverlayLoadingOsd?.('Overlay loading...');
|
||||
if (args.isMacOSPlatform) {
|
||||
args.showOverlayLoadingOsd?.('Overlay loading...');
|
||||
}
|
||||
}
|
||||
args.mainWindow.hide();
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
args.mainWindow.setIgnoreMouseEvents(false);
|
||||
args.ensureOverlayWindowLevel(args.mainWindow);
|
||||
args.mainWindow.show();
|
||||
args.mainWindow.focus();
|
||||
showPassiveVisibleOverlay();
|
||||
args.enforceOverlayLayerOrder();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
@@ -72,8 +84,8 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
}
|
||||
}
|
||||
|
||||
if (args.isMacOSPlatform) {
|
||||
args.mainWindow.hide();
|
||||
if (args.isMacOSPlatform || args.isWindowsPlatform) {
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
@@ -83,10 +95,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
|
||||
args.updateVisibleOverlayBounds(fallbackBounds);
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
args.mainWindow.setIgnoreMouseEvents(false);
|
||||
args.ensureOverlayWindowLevel(args.mainWindow);
|
||||
args.mainWindow.show();
|
||||
args.mainWindow.focus();
|
||||
showPassiveVisibleOverlay();
|
||||
args.enforceOverlayLayerOrder();
|
||||
args.syncOverlayShortcuts();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { WindowGeometry } from '../../types';
|
||||
import { createLogger } from '../../logger';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
|
||||
const { BrowserWindow: ElectronBrowserWindow } = electron;
|
||||
const logger = createLogger('main:overlay-window');
|
||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||
|
||||
@@ -20,7 +18,7 @@ function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind)
|
||||
.loadFile(htmlPath, {
|
||||
query: { layer },
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
.catch((err) => {
|
||||
logger.error('Failed to load HTML file:', err);
|
||||
});
|
||||
}
|
||||
@@ -65,6 +63,11 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
|
||||
window.setFullScreenable(false);
|
||||
return;
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
window.moveTop();
|
||||
return;
|
||||
}
|
||||
window.setAlwaysOnTop(true);
|
||||
}
|
||||
|
||||
@@ -92,7 +95,8 @@ export function createOverlayWindow(
|
||||
onWindowClosed: (kind: OverlayWindowKind) => void;
|
||||
},
|
||||
): BrowserWindow {
|
||||
const window = new ElectronBrowserWindow({
|
||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
width: 800,
|
||||
height: 600,
|
||||
@@ -106,6 +110,7 @@ export function createOverlayWindow(
|
||||
hasShadow: false,
|
||||
focusable: true,
|
||||
acceptFirstMouse: true,
|
||||
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '..', '..', 'preload.js'),
|
||||
contextIsolation: true,
|
||||
@@ -162,6 +167,9 @@ export function createOverlayWindow(
|
||||
window.on('blur', () => {
|
||||
if (!window.isDestroyed()) {
|
||||
options.ensureOverlayWindowLevel(window);
|
||||
if (kind === 'visible' && window.isVisible()) {
|
||||
window.moveTop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
|
||||
42
src/logger.test.ts
Normal file
42
src/logger.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { resolveDefaultLogFilePath } from './logger';
|
||||
|
||||
test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
|
||||
const resolved = resolveDefaultLogFilePath({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
path.normalize(resolved),
|
||||
path.normalize(
|
||||
path.join(
|
||||
'C:\\Users\\tester\\AppData\\Roaming',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDefaultLogFilePath uses .config on linux', () => {
|
||||
const resolved = resolveDefaultLogFilePath({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
resolved,
|
||||
path.join(
|
||||
'/home/tester',
|
||||
'.config',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -116,8 +116,26 @@ function resolveLogFilePath(): string {
|
||||
if (envPath) {
|
||||
return envPath;
|
||||
}
|
||||
return resolveDefaultLogFilePath({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
appDataDir: process.env.APPDATA,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDefaultLogFilePath(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
appDataDir?: string;
|
||||
}): string {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
return path.join(os.homedir(), '.config', 'SubMiner', 'logs', `SubMiner-${date}.log`);
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const homeDir = options?.homeDir ?? os.homedir();
|
||||
const baseDir =
|
||||
platform === 'win32'
|
||||
? path.join(options?.appDataDir?.trim() || path.join(homeDir, 'AppData', 'Roaming'), 'SubMiner')
|
||||
: path.join(homeDir, '.config', 'SubMiner');
|
||||
return path.join(baseDir, 'logs', `SubMiner-${date}.log`);
|
||||
}
|
||||
|
||||
function appendToLogFile(line: string): void {
|
||||
|
||||
@@ -2,32 +2,53 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
normalizeStartupArgv,
|
||||
normalizeLaunchMpvTargets,
|
||||
sanitizeHelpEnv,
|
||||
sanitizeLaunchMpvEnv,
|
||||
sanitizeStartupEnv,
|
||||
sanitizeBackgroundEnv,
|
||||
shouldDetachBackgroundLaunch,
|
||||
shouldHandleHelpOnlyAtEntry,
|
||||
shouldHandleLaunchMpvAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
test('normalizeStartupArgv defaults no-arg startup to --start --background', () => {
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--start',
|
||||
'--background',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
normalizeStartupArgv(['SubMiner.AppImage', '--password-store', 'gnome-libsecret'], {}),
|
||||
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
|
||||
);
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--background'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--background',
|
||||
'--start',
|
||||
]);
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--help'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--help',
|
||||
]);
|
||||
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
||||
const originalPlatform = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
||||
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--start',
|
||||
'--background',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
normalizeStartupArgv(['SubMiner.AppImage', '--password-store', 'gnome-libsecret'], {}),
|
||||
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
|
||||
);
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--background'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--background',
|
||||
'--start',
|
||||
]);
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--help'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--help',
|
||||
]);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeStartupArgv defaults no-arg Windows startup to --start only', () => {
|
||||
const originalPlatform = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.exe'], {}), ['SubMiner.exe', '--start']);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||
@@ -37,6 +58,18 @@ test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], { ELECTRON_RUN_AS_NODE: '1' }), false);
|
||||
});
|
||||
|
||||
test('launch-mpv entry helpers detect and normalize targets', () => {
|
||||
assert.equal(shouldHandleLaunchMpvAtEntry(['SubMiner.exe', '--launch-mpv'], {}), true);
|
||||
assert.equal(
|
||||
shouldHandleLaunchMpvAtEntry(['SubMiner.exe', '--launch-mpv'], { ELECTRON_RUN_AS_NODE: '1' }),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv']), []);
|
||||
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', 'C:\\a.mkv']), [
|
||||
'C:\\a.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sanitizeStartupEnv suppresses warnings and lsfg layer', () => {
|
||||
const env = sanitizeStartupEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
@@ -53,6 +86,14 @@ test('sanitizeHelpEnv suppresses warnings and lsfg layer', () => {
|
||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
||||
});
|
||||
|
||||
test('sanitizeLaunchMpvEnv suppresses warnings and lsfg layer', () => {
|
||||
const env = sanitizeLaunchMpvEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
});
|
||||
assert.equal(env.NODE_NO_WARNINGS, '1');
|
||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
||||
});
|
||||
|
||||
test('sanitizeBackgroundEnv marks background child and keeps warning suppression', () => {
|
||||
const env = sanitizeBackgroundEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
|
||||
@@ -45,6 +45,9 @@ export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): st
|
||||
|
||||
const effectiveArgs = removePassiveStartupArgs(argv.slice(1));
|
||||
if (effectiveArgs.length === 0) {
|
||||
if (process.platform === 'win32') {
|
||||
return [...argv, START_ARG];
|
||||
}
|
||||
return [...argv, START_ARG, BACKGROUND_ARG];
|
||||
}
|
||||
|
||||
@@ -72,6 +75,15 @@ export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessE
|
||||
return args.help && !shouldStartApp(args);
|
||||
}
|
||||
|
||||
export function shouldHandleLaunchMpvAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||
return parseCliArgs(argv).launchMpv;
|
||||
}
|
||||
|
||||
export function normalizeLaunchMpvTargets(argv: string[]): string[] {
|
||||
return parseCliArgs(argv).launchMpvTargets;
|
||||
}
|
||||
|
||||
export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env = { ...baseEnv };
|
||||
if (!env.NODE_NO_WARNINGS) {
|
||||
@@ -85,6 +97,10 @@ export function sanitizeHelpEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return sanitizeStartupEnv(baseEnv);
|
||||
}
|
||||
|
||||
export function sanitizeLaunchMpvEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return sanitizeStartupEnv(baseEnv);
|
||||
}
|
||||
|
||||
export function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env = sanitizeStartupEnv(baseEnv);
|
||||
env[BACKGROUND_CHILD_ENV] = '1';
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { app, dialog } from 'electron';
|
||||
import { printHelp } from './cli/help';
|
||||
import {
|
||||
normalizeLaunchMpvTargets,
|
||||
normalizeStartupArgv,
|
||||
sanitizeStartupEnv,
|
||||
sanitizeBackgroundEnv,
|
||||
sanitizeHelpEnv,
|
||||
sanitizeLaunchMpvEnv,
|
||||
shouldDetachBackgroundLaunch,
|
||||
shouldHandleHelpOnlyAtEntry,
|
||||
shouldHandleLaunchMpvAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||
|
||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||
|
||||
@@ -46,4 +51,21 @@ if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
require('./main.js');
|
||||
if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
|
||||
applySanitizedEnv(sanitizedEnv);
|
||||
void app.whenReady().then(() => {
|
||||
const result = launchWindowsMpv(
|
||||
normalizeLaunchMpvTargets(process.argv),
|
||||
createWindowsMpvLaunchDeps({
|
||||
getEnv: (name) => process.env[name],
|
||||
showError: (title, content) => {
|
||||
dialog.showErrorBox(title, content);
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.exit(result.ok ? 0 : 1);
|
||||
});
|
||||
} else {
|
||||
require('./main.js');
|
||||
}
|
||||
|
||||
160
src/main.ts
160
src/main.ts
@@ -99,6 +99,7 @@ import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||
import { RuntimeOptionsManager } from './runtime-options';
|
||||
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
|
||||
import { createLogger, setLogLevel, type LogLevelSource } from './logger';
|
||||
import { resolveDefaultLogFilePath } from './logger';
|
||||
import {
|
||||
commandNeedsOverlayRuntime,
|
||||
parseArgs,
|
||||
@@ -310,12 +311,17 @@ import {
|
||||
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
||||
createOpenFirstRunSetupWindowHandler,
|
||||
parseFirstRunSetupSubmissionUrl,
|
||||
type FirstRunSetupAction,
|
||||
type FirstRunSetupSubmission,
|
||||
} from './main/runtime/first-run-setup-window';
|
||||
import {
|
||||
detectInstalledFirstRunPlugin,
|
||||
installFirstRunPluginToDefaultLocation,
|
||||
} from './main/runtime/first-run-setup-plugin';
|
||||
import {
|
||||
applyWindowsMpvShortcuts,
|
||||
detectWindowsMpvShortcuts,
|
||||
resolveWindowsMpvShortcutPaths,
|
||||
} from './main/runtime/windows-mpv-shortcuts';
|
||||
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
||||
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
|
||||
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
|
||||
@@ -362,6 +368,10 @@ import { createMediaRuntimeService } from './main/media-runtime';
|
||||
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
|
||||
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
|
||||
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
} from './main/runtime/yomitan-anki-server';
|
||||
import {
|
||||
type AnilistMediaGuessRuntimeState,
|
||||
type StartupState,
|
||||
@@ -401,7 +411,11 @@ if (process.platform === 'linux') {
|
||||
app.setName('SubMiner');
|
||||
|
||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||
const DEFAULT_MPV_LOG_FILE = path.join(os.homedir(), '.cache', 'SubMiner', 'mp.log');
|
||||
const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
appDataDir: process.env.APPDATA,
|
||||
});
|
||||
const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize';
|
||||
const ANILIST_SETUP_RESPONSE_TYPE = 'token';
|
||||
const ANILIST_DEFAULT_CLIENT_ID = '36084';
|
||||
@@ -462,6 +476,8 @@ function applyJellyfinMpvDefaults(
|
||||
}
|
||||
|
||||
const CONFIG_DIR = resolveConfigDir({
|
||||
platform: process.platform,
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
@@ -480,7 +496,7 @@ const configService = (() => {
|
||||
{
|
||||
logError: (details) => console.error(details),
|
||||
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
|
||||
quit: () => app.quit(),
|
||||
quit: () => requestAppQuit(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -568,11 +584,23 @@ if (!fs.existsSync(USER_DATA_PATH)) {
|
||||
}
|
||||
app.setPath('userData', USER_DATA_PATH);
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function requestAppQuit(): void {
|
||||
if (!forceQuitTimer) {
|
||||
forceQuitTimer = setTimeout(() => {
|
||||
logger.warn('App quit timed out; forcing process exit.');
|
||||
app.exit(0);
|
||||
}, 2000);
|
||||
}
|
||||
app.quit();
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
requestAppQuit();
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
app.quit();
|
||||
requestAppQuit();
|
||||
});
|
||||
|
||||
const overlayManager = createOverlayManager();
|
||||
@@ -623,7 +651,13 @@ const appState = createAppState({
|
||||
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
|
||||
});
|
||||
let firstRunSetupMessage: string | null = null;
|
||||
const resolveWindowsMpvShortcutRuntimePaths = () =>
|
||||
resolveWindowsMpvShortcutPaths({
|
||||
appDataDir: app.getPath('appData'),
|
||||
desktopDir: app.getPath('desktop'),
|
||||
});
|
||||
const firstRunSetupService = createFirstRunSetupService({
|
||||
platform: process.platform,
|
||||
configDir: CONFIG_DIR,
|
||||
getYomitanDictionaryCount: async () => {
|
||||
await ensureYomitanExtensionLoaded();
|
||||
@@ -650,6 +684,31 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
}),
|
||||
detectWindowsMpvShortcuts: () => {
|
||||
if (process.platform !== 'win32') {
|
||||
return {
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
};
|
||||
}
|
||||
return detectWindowsMpvShortcuts(resolveWindowsMpvShortcutRuntimePaths());
|
||||
},
|
||||
applyWindowsMpvShortcuts: async (preferences) => {
|
||||
if (process.platform !== 'win32') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 'unknown' as const,
|
||||
message: '',
|
||||
};
|
||||
}
|
||||
return applyWindowsMpvShortcuts({
|
||||
preferences,
|
||||
paths: resolveWindowsMpvShortcutRuntimePaths(),
|
||||
exePath: process.execPath,
|
||||
writeShortcutLink: (shortcutPath, operation, details) =>
|
||||
shell.writeShortcutLink(shortcutPath, operation, details),
|
||||
});
|
||||
},
|
||||
onStateChanged: (state) => {
|
||||
appState.firstRunSetupCompleted = state.status === 'completed';
|
||||
if (appTray) {
|
||||
@@ -969,8 +1028,22 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
|
||||
appState.shortcutsRegistered = registered;
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
isMacOSPlatform: () => process.platform === 'darwin',
|
||||
isTrackedMpvWindowFocused: () => appState.windowTracker?.isFocused() ?? false,
|
||||
isOverlayShortcutContextActive: () => {
|
||||
if (process.platform !== 'win32') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const windowTracker = appState.windowTracker;
|
||||
if (!windowTracker || !windowTracker.isTracking()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return windowTracker.isTargetWindowFocused();
|
||||
},
|
||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||
openRuntimeOptionsPalette: () => {
|
||||
openRuntimeOptionsPalette();
|
||||
@@ -1080,22 +1153,26 @@ const configHotReloadRuntime = createConfigHotReloadRuntime(
|
||||
);
|
||||
|
||||
const buildDictionaryRootsHandler = createBuildDictionaryRootsMainHandler({
|
||||
platform: process.platform,
|
||||
dirname: __dirname,
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
userDataPath: USER_DATA_PATH,
|
||||
appUserDataPath: app.getPath('userData'),
|
||||
homeDir: os.homedir(),
|
||||
appDataDir: process.env.APPDATA,
|
||||
cwd: process.cwd(),
|
||||
joinPath: (...parts) => path.join(...parts),
|
||||
});
|
||||
const buildFrequencyDictionaryRootsHandler = createBuildFrequencyDictionaryRootsMainHandler({
|
||||
platform: process.platform,
|
||||
dirname: __dirname,
|
||||
appPath: app.getAppPath(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
userDataPath: USER_DATA_PATH,
|
||||
appUserDataPath: app.getPath('userData'),
|
||||
homeDir: os.homedir(),
|
||||
appDataDir: process.env.APPDATA,
|
||||
cwd: process.cwd(),
|
||||
joinPath: (...parts) => path.join(...parts),
|
||||
});
|
||||
@@ -1292,6 +1369,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
overlayShortcutsRuntime.syncOverlayShortcuts();
|
||||
},
|
||||
isMacOSPlatform: () => process.platform === 'darwin',
|
||||
isWindowsPlatform: () => process.platform === 'win32',
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
showMpvOsd(message);
|
||||
},
|
||||
@@ -1687,28 +1765,37 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
canFinish: snapshot.canFinish,
|
||||
pluginStatus: snapshot.pluginStatus,
|
||||
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
|
||||
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
|
||||
message: firstRunSetupMessage,
|
||||
};
|
||||
},
|
||||
buildSetupHtml: (model) => buildFirstRunSetupHtml(model),
|
||||
parseSubmissionUrl: (rawUrl) => parseFirstRunSetupSubmissionUrl(rawUrl),
|
||||
handleAction: async (action: FirstRunSetupAction) => {
|
||||
if (action === 'install-plugin') {
|
||||
handleAction: async (submission: FirstRunSetupSubmission) => {
|
||||
if (submission.action === 'install-plugin') {
|
||||
const snapshot = await firstRunSetupService.installMpvPlugin();
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
if (action === 'open-yomitan-settings') {
|
||||
if (submission.action === 'configure-windows-mpv-shortcuts') {
|
||||
const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({
|
||||
startMenuEnabled: submission.startMenuEnabled === true,
|
||||
desktopEnabled: submission.desktopEnabled === true,
|
||||
});
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
if (submission.action === 'open-yomitan-settings') {
|
||||
openYomitanSettings();
|
||||
firstRunSetupMessage = 'Opened Yomitan settings. Install dictionaries, then refresh status.';
|
||||
return;
|
||||
}
|
||||
if (action === 'refresh') {
|
||||
if (submission.action === 'refresh') {
|
||||
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
|
||||
firstRunSetupMessage = snapshot.message;
|
||||
return;
|
||||
}
|
||||
if (action === 'skip-plugin') {
|
||||
if (submission.action === 'skip-plugin') {
|
||||
await firstRunSetupService.skipPluginInstall();
|
||||
firstRunSetupMessage = 'mpv plugin installation skipped.';
|
||||
return;
|
||||
@@ -1731,6 +1818,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
||||
await firstRunSetupService.markSetupCancelled();
|
||||
},
|
||||
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
|
||||
shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode,
|
||||
quitApp: () => requestAppQuit(),
|
||||
clearSetupWindow: () => {
|
||||
appState.firstRunSetupWindow = null;
|
||||
},
|
||||
@@ -2202,6 +2291,14 @@ const {
|
||||
clearJellyfinSetupWindow: () => {
|
||||
appState.jellyfinSetupWindow = null;
|
||||
},
|
||||
getFirstRunSetupWindow: () => appState.firstRunSetupWindow,
|
||||
clearFirstRunSetupWindow: () => {
|
||||
appState.firstRunSetupWindow = null;
|
||||
},
|
||||
getYomitanSettingsWindow: () => appState.yomitanSettingsWindow,
|
||||
clearYomitanSettingsWindow: () => {
|
||||
appState.yomitanSettingsWindow = null;
|
||||
},
|
||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||
stopDiscordPresenceService: () => {
|
||||
void appState.discordPresenceService?.stop();
|
||||
@@ -2266,10 +2363,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
failHandlers: {
|
||||
logError: (details) => logger.error(details),
|
||||
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
|
||||
setExitCode: (code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
quit: () => app.quit(),
|
||||
quit: () => requestAppQuit(),
|
||||
},
|
||||
},
|
||||
criticalConfigErrorMainDeps: {
|
||||
@@ -2277,10 +2371,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
failHandlers: {
|
||||
logError: (message) => logger.error(message),
|
||||
showErrorBox: (title, message) => dialog.showErrorBox(title, message),
|
||||
setExitCode: (code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
quit: () => app.quit(),
|
||||
quit: () => requestAppQuit(),
|
||||
},
|
||||
},
|
||||
appReadyRuntimeMainDeps: {
|
||||
@@ -2476,7 +2567,7 @@ const { runAndApplyStartupState } = runtimeRegistry.startup.createStartupRuntime
|
||||
setExitCode: (code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
quitApp: () => app.quit(),
|
||||
quitApp: () => requestAppQuit(),
|
||||
logGenerateConfigError: (message) => logger.error(message),
|
||||
startAppLifecycle,
|
||||
}),
|
||||
@@ -2510,6 +2601,7 @@ const handleCliCommand = createCliCommandRuntimeHandler({
|
||||
const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
|
||||
getInitialArgs: () => appState.initialArgs,
|
||||
isBackgroundMode: () => appState.backgroundMode,
|
||||
shouldEnsureTrayOnStartup: () => process.platform === 'win32',
|
||||
ensureTray: () => ensureTray(),
|
||||
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
||||
hasImmersionTracker: () => Boolean(appState.immersionTracker),
|
||||
@@ -2526,7 +2618,6 @@ const {
|
||||
createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler,
|
||||
updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler,
|
||||
tokenizeSubtitle,
|
||||
isTokenizationWarmupReady,
|
||||
createMecabTokenizerAndCheck,
|
||||
prewarmSubtitleDictionaries,
|
||||
startBackgroundWarmups,
|
||||
@@ -2541,7 +2632,7 @@ const {
|
||||
scheduleQuitCheck: (callback) => {
|
||||
setTimeout(callback, 500);
|
||||
},
|
||||
quitApp: () => app.quit(),
|
||||
quitApp: () => requestAppQuit(),
|
||||
reportJellyfinRemoteStopped: () => {
|
||||
void reportJellyfinRemoteStopped();
|
||||
},
|
||||
@@ -2566,12 +2657,6 @@ const {
|
||||
}
|
||||
mediaRuntime.updateCurrentMediaPath(path);
|
||||
},
|
||||
signalAutoplayReadyIfWarm: (path) => {
|
||||
if (!isTokenizationWarmupReady()) {
|
||||
return;
|
||||
}
|
||||
maybeSignalPluginAutoplayReady({ text: path, tokens: null }, { forceWhilePaused: true });
|
||||
},
|
||||
restoreMpvSubVisibility: () => {
|
||||
restoreOverlayMpvSubtitles();
|
||||
},
|
||||
@@ -2849,13 +2934,7 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
|
||||
let lastSyncedYomitanAnkiServer: string | null = null;
|
||||
|
||||
function getPreferredYomitanAnkiServerUrl(): string {
|
||||
const config = getResolvedConfig().ankiConnect;
|
||||
if (config.proxy?.enabled) {
|
||||
const host = config.proxy.host || '127.0.0.1';
|
||||
const port = config.proxy.port || 8766;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
return config.url;
|
||||
return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect);
|
||||
}
|
||||
|
||||
function getYomitanParserRuntimeDeps() {
|
||||
@@ -2894,7 +2973,7 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
||||
},
|
||||
},
|
||||
{
|
||||
forceOverride: getResolvedConfig().ankiConnect.proxy?.enabled === true,
|
||||
forceOverride: shouldForceOverrideYomitanAnkiServer(getResolvedConfig().ankiConnect),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3244,7 +3323,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
overlayModalRuntime.notifyOverlayModalOpened(modal);
|
||||
},
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
quitApp: () => app.quit(),
|
||||
quitApp: () => requestAppQuit(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
|
||||
getCurrentSubtitleRaw: () => appState.currentSubText,
|
||||
@@ -3345,7 +3424,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
|
||||
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||
stopApp: () => app.quit(),
|
||||
stopApp: () => requestAppQuit(),
|
||||
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
|
||||
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
|
||||
@@ -3395,11 +3474,12 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
setVisibleOverlayVisible: (visible) => setVisibleOverlayVisible(visible),
|
||||
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
|
||||
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
|
||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
||||
openAnilistSetupWindow: () => openAnilistSetupWindow(),
|
||||
quitApp: () => app.quit(),
|
||||
quitApp: () => requestAppQuit(),
|
||||
},
|
||||
ensureTrayDeps: {
|
||||
getTray: () => appTray,
|
||||
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
import {
|
||||
refreshOverlayShortcutsRuntime,
|
||||
registerOverlayShortcuts,
|
||||
shouldActivateOverlayShortcuts,
|
||||
syncOverlayShortcutsRuntime,
|
||||
unregisterOverlayShortcutsRuntime,
|
||||
} from '../core/services/overlay-shortcut';
|
||||
} from '../core/services';
|
||||
import { runOverlayShortcutLocalFallback } from '../core/services/overlay-shortcut-handler';
|
||||
|
||||
export interface OverlayShortcutRuntimeServiceInput {
|
||||
@@ -17,8 +16,7 @@ export interface OverlayShortcutRuntimeServiceInput {
|
||||
getShortcutsRegistered: () => boolean;
|
||||
setShortcutsRegistered: (registered: boolean) => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
isMacOSPlatform: () => boolean;
|
||||
isTrackedMpvWindowFocused: () => boolean;
|
||||
isOverlayShortcutContextActive?: () => boolean;
|
||||
showMpvOsd: (text: string) => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJimaku: () => void;
|
||||
@@ -93,11 +91,7 @@ export function createOverlayShortcutsRuntimeService(
|
||||
};
|
||||
|
||||
const shouldOverlayShortcutsBeActive = () =>
|
||||
shouldActivateOverlayShortcuts({
|
||||
overlayRuntimeInitialized: input.isOverlayRuntimeInitialized(),
|
||||
isMacOSPlatform: input.isMacOSPlatform(),
|
||||
trackedMpvWindowFocused: input.isTrackedMpvWindowFocused(),
|
||||
});
|
||||
input.isOverlayRuntimeInitialized() && (input.isOverlayShortcutContextActive?.() ?? true);
|
||||
|
||||
return {
|
||||
tryHandleOverlayShortcutLocalFallback: (inputEvent) =>
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
isMacOSPlatform: () => boolean;
|
||||
isWindowsPlatform: () => boolean;
|
||||
showOverlayLoadingOsd: (message: string) => void;
|
||||
resolveFallbackBounds: () => WindowGeometry;
|
||||
}
|
||||
@@ -45,6 +46,7 @@ export function createOverlayVisibilityRuntimeService(
|
||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||
isMacOSPlatform: deps.isMacOSPlatform(),
|
||||
isWindowsPlatform: deps.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
});
|
||||
|
||||
@@ -29,12 +29,16 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
||||
destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'),
|
||||
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
|
||||
destroyFirstRunSetupWindow: () => calls.push('destroy-first-run-window'),
|
||||
clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'),
|
||||
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 22);
|
||||
assert.equal(calls.length, 26);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
|
||||
@@ -19,6 +19,10 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
clearAnilistSetupWindow: () => void;
|
||||
destroyJellyfinSetupWindow: () => void;
|
||||
clearJellyfinSetupWindow: () => void;
|
||||
destroyFirstRunSetupWindow: () => void;
|
||||
clearFirstRunSetupWindow: () => void;
|
||||
destroyYomitanSettingsWindow: () => void;
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
@@ -43,6 +47,10 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.clearAnilistSetupWindow();
|
||||
deps.destroyJellyfinSetupWindow();
|
||||
deps.clearJellyfinSetupWindow();
|
||||
deps.destroyFirstRunSetupWindow();
|
||||
deps.clearFirstRunSetupWindow();
|
||||
deps.destroyYomitanSettingsWindow();
|
||||
deps.clearYomitanSettingsWindow();
|
||||
deps.stopJellyfinRemoteSession();
|
||||
deps.stopDiscordPresenceService();
|
||||
};
|
||||
|
||||
@@ -46,6 +46,12 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
||||
getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }),
|
||||
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
|
||||
getFirstRunSetupWindow: () => ({ destroy: () => calls.push('destroy-first-run-window') }),
|
||||
clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'),
|
||||
getYomitanSettingsWindow: () => ({
|
||||
destroy: () => calls.push('destroy-yomitan-settings-window'),
|
||||
}),
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
@@ -61,6 +67,8 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
assert.ok(calls.includes('clear-reconnect-ref'));
|
||||
assert.ok(calls.includes('destroy-immersion'));
|
||||
assert.ok(calls.includes('clear-immersion-ref'));
|
||||
assert.ok(calls.includes('destroy-first-run-window'));
|
||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.equal(reconnectTimer, null);
|
||||
@@ -95,6 +103,10 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
clearAnilistSetupWindow: () => {},
|
||||
getJellyfinSetupWindow: () => null,
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
getFirstRunSetupWindow: () => null,
|
||||
clearFirstRunSetupWindow: () => {},
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
@@ -44,6 +44,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearAnilistSetupWindow: () => void;
|
||||
getJellyfinSetupWindow: () => Destroyable | null;
|
||||
clearJellyfinSetupWindow: () => void;
|
||||
getFirstRunSetupWindow: () => Destroyable | null;
|
||||
clearFirstRunSetupWindow: () => void;
|
||||
getYomitanSettingsWindow: () => Destroyable | null;
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
@@ -98,6 +102,14 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
deps.getJellyfinSetupWindow()?.destroy();
|
||||
},
|
||||
clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(),
|
||||
destroyFirstRunSetupWindow: () => {
|
||||
deps.getFirstRunSetupWindow()?.destroy();
|
||||
},
|
||||
clearFirstRunSetupWindow: () => deps.clearFirstRunSetupWindow(),
|
||||
destroyYomitanSettingsWindow: () => {
|
||||
deps.getYomitanSettingsWindow()?.destroy();
|
||||
},
|
||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
|
||||
@@ -35,6 +35,10 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
clearAnilistSetupWindow: () => {},
|
||||
getJellyfinSetupWindow: () => null,
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
getFirstRunSetupWindow: () => null,
|
||||
clearFirstRunSetupWindow: () => {},
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
test('dictionary roots main handler returns expected root list', () => {
|
||||
const roots = createBuildDictionaryRootsMainHandler({
|
||||
platform: 'darwin',
|
||||
dirname: '/repo/dist/main',
|
||||
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
|
||||
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
|
||||
@@ -44,6 +45,7 @@ test('jlpt dictionary runtime main deps builder maps search paths and log prefix
|
||||
|
||||
test('frequency dictionary roots main handler returns expected root list', () => {
|
||||
const roots = createBuildFrequencyDictionaryRootsMainHandler({
|
||||
platform: 'darwin',
|
||||
dirname: '/repo/dist/main',
|
||||
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
|
||||
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
|
||||
@@ -59,6 +61,42 @@ test('frequency dictionary roots main handler returns expected root list', () =>
|
||||
assert.equal(roots[10], '/repo');
|
||||
});
|
||||
|
||||
test('dictionary roots main handler uses APPDATA-style roots on windows', () => {
|
||||
const roots = createBuildDictionaryRootsMainHandler({
|
||||
platform: 'win32',
|
||||
dirname: 'C:\\repo\\dist\\main',
|
||||
appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar',
|
||||
resourcesPath: 'C:\\Program Files\\SubMiner\\resources',
|
||||
userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
|
||||
appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
|
||||
homeDir: 'C:\\Users\\a',
|
||||
appDataDir: 'C:\\Users\\a\\AppData\\Roaming',
|
||||
cwd: 'C:\\repo',
|
||||
joinPath: (...parts) => parts.join('\\'),
|
||||
})();
|
||||
|
||||
assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false);
|
||||
assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true);
|
||||
});
|
||||
|
||||
test('frequency dictionary roots main handler uses APPDATA-style roots on windows', () => {
|
||||
const roots = createBuildFrequencyDictionaryRootsMainHandler({
|
||||
platform: 'win32',
|
||||
dirname: 'C:\\repo\\dist\\main',
|
||||
appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar',
|
||||
resourcesPath: 'C:\\Program Files\\SubMiner\\resources',
|
||||
userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
|
||||
appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
|
||||
homeDir: 'C:\\Users\\a',
|
||||
appDataDir: 'C:\\Users\\a\\AppData\\Roaming',
|
||||
cwd: 'C:\\repo',
|
||||
joinPath: (...parts) => parts.join('\\'),
|
||||
})();
|
||||
|
||||
assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false);
|
||||
assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true);
|
||||
});
|
||||
|
||||
test('frequency dictionary runtime main deps builder maps search paths/source and log prefix', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildFrequencyDictionaryRuntimeMainDepsHandler({
|
||||
|
||||
@@ -3,53 +3,93 @@ import type { FrequencyDictionaryLookup, JlptLevel } from '../../types';
|
||||
type JlptLookup = (term: string) => JlptLevel | null;
|
||||
|
||||
export function createBuildDictionaryRootsMainHandler(deps: {
|
||||
platform: NodeJS.Platform;
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
userDataPath: string;
|
||||
appUserDataPath: string;
|
||||
homeDir: string;
|
||||
appDataDir?: string;
|
||||
cwd: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
}) {
|
||||
return () => [
|
||||
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'),
|
||||
deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'),
|
||||
deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'),
|
||||
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'),
|
||||
deps.userDataPath,
|
||||
deps.appUserDataPath,
|
||||
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, '.config', 'subminer'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
|
||||
deps.cwd,
|
||||
];
|
||||
return () => {
|
||||
const platformRoots =
|
||||
deps.platform === 'win32'
|
||||
? [
|
||||
deps.joinPath(
|
||||
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
|
||||
'SubMiner',
|
||||
),
|
||||
deps.joinPath(
|
||||
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
|
||||
'subminer',
|
||||
),
|
||||
]
|
||||
: [
|
||||
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, '.config', 'subminer'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
|
||||
];
|
||||
|
||||
return [
|
||||
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'),
|
||||
deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'),
|
||||
deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'),
|
||||
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'),
|
||||
deps.userDataPath,
|
||||
deps.appUserDataPath,
|
||||
...platformRoots,
|
||||
deps.cwd,
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
export function createBuildFrequencyDictionaryRootsMainHandler(deps: {
|
||||
platform: NodeJS.Platform;
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
userDataPath: string;
|
||||
appUserDataPath: string;
|
||||
homeDir: string;
|
||||
appDataDir?: string;
|
||||
cwd: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
}) {
|
||||
return () => [
|
||||
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'),
|
||||
deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'),
|
||||
deps.joinPath(deps.resourcesPath, 'frequency-dictionary'),
|
||||
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'),
|
||||
deps.userDataPath,
|
||||
deps.appUserDataPath,
|
||||
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, '.config', 'subminer'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
|
||||
deps.cwd,
|
||||
];
|
||||
return () => {
|
||||
const platformRoots =
|
||||
deps.platform === 'win32'
|
||||
? [
|
||||
deps.joinPath(
|
||||
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
|
||||
'SubMiner',
|
||||
),
|
||||
deps.joinPath(
|
||||
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
|
||||
'subminer',
|
||||
),
|
||||
]
|
||||
: [
|
||||
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, '.config', 'subminer'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
|
||||
];
|
||||
|
||||
return [
|
||||
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'),
|
||||
deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'),
|
||||
deps.joinPath(deps.resourcesPath, 'frequency-dictionary'),
|
||||
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'),
|
||||
deps.userDataPath,
|
||||
deps.appUserDataPath,
|
||||
...platformRoots,
|
||||
deps.cwd,
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
export function createBuildJlptDictionaryRuntimeMainDepsHandler(deps: {
|
||||
|
||||
@@ -54,8 +54,10 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
|
||||
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginEntrypointPath), { recursive: true });
|
||||
fs.mkdirSync(installPaths.pluginDir, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
||||
fs.writeFileSync(path.join(installPaths.scriptsDir, 'subminer-loader.lua'), '-- old loader');
|
||||
fs.writeFileSync(path.join(installPaths.pluginDir, 'old.lua'), '-- old plugin');
|
||||
fs.writeFileSync(installPaths.pluginConfigPath, 'old=true\n');
|
||||
|
||||
@@ -72,7 +74,7 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
|
||||
assert.equal(result.pluginInstallStatus, 'installed');
|
||||
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(installPaths.pluginDir, 'main.lua'), 'utf8'),
|
||||
fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'),
|
||||
'-- packaged plugin',
|
||||
);
|
||||
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n');
|
||||
@@ -83,6 +85,10 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
|
||||
scriptsDirEntries.some((entry) => entry.startsWith('subminer.bak.')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
scriptsDirEntries.some((entry) => entry.startsWith('subminer-loader.lua.bak.')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
scriptOptsEntries.some((entry) => entry.startsWith('subminer.conf.bak.')),
|
||||
true,
|
||||
@@ -90,17 +96,65 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
|
||||
});
|
||||
});
|
||||
|
||||
test('installFirstRunPluginToDefaultLocation reports unsupported platforms', () => {
|
||||
const result = installFirstRunPluginToDefaultLocation({
|
||||
platform: 'win32',
|
||||
homeDir: '/tmp/home',
|
||||
xdgConfigHome: '/tmp/xdg',
|
||||
dirname: '/tmp/dist/main/runtime',
|
||||
appPath: '/tmp/app',
|
||||
resourcesPath: '/tmp/resources',
|
||||
});
|
||||
test('installFirstRunPluginToDefaultLocation installs plugin to Windows mpv defaults', () => {
|
||||
withTempDir((root) => {
|
||||
const resourcesPath = path.join(root, 'resources');
|
||||
const pluginRoot = path.join(resourcesPath, 'plugin');
|
||||
const homeDir = path.join(root, 'home');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.pluginInstallStatus, 'failed');
|
||||
assert.match(result.message, /not supported/i);
|
||||
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
|
||||
|
||||
const result = installFirstRunPluginToDefaultLocation({
|
||||
platform: 'win32',
|
||||
homeDir,
|
||||
dirname: path.join(root, 'dist', 'main', 'runtime'),
|
||||
appPath: path.join(root, 'app'),
|
||||
resourcesPath,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.pluginInstallStatus, 'installed');
|
||||
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'),
|
||||
'-- packaged plugin',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'configured=true\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('installFirstRunPluginToDefaultLocation rewrites Windows plugin socket_path', () => {
|
||||
withTempDir((root) => {
|
||||
const resourcesPath = path.join(root, 'resources');
|
||||
const pluginRoot = path.join(resourcesPath, 'plugin');
|
||||
const homeDir = path.join(root, 'home');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
|
||||
|
||||
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, 'subminer.conf'),
|
||||
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
|
||||
const result = installFirstRunPluginToDefaultLocation({
|
||||
platform: 'win32',
|
||||
homeDir,
|
||||
dirname: path.join(root, 'dist', 'main', 'runtime'),
|
||||
appPath: path.join(root, 'app'),
|
||||
resourcesPath,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'binary_path=\nsocket_path=\\\\.\\pipe\\subminer-socket\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,25 @@ function backupExistingPath(targetPath: string): void {
|
||||
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
|
||||
}
|
||||
|
||||
function resolveLegacyPluginLoaderPath(installPaths: MpvInstallPaths): string {
|
||||
return path.join(installPaths.scriptsDir, 'subminer.lua');
|
||||
}
|
||||
|
||||
function resolveLegacyPluginDebugLoaderPath(installPaths: MpvInstallPaths): string {
|
||||
return path.join(installPaths.scriptsDir, 'subminer-loader.lua');
|
||||
}
|
||||
|
||||
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const updated = content.replace(
|
||||
/^socket_path=.*$/m,
|
||||
'socket_path=\\\\.\\pipe\\subminer-socket',
|
||||
);
|
||||
if (updated !== content) {
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackagedFirstRunPluginAssets(deps: {
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
@@ -32,7 +51,11 @@ export function resolvePackagedFirstRunPluginAssets(deps: {
|
||||
for (const root of roots) {
|
||||
const pluginDirSource = joinPath(root, 'subminer');
|
||||
const pluginConfigSource = joinPath(root, 'subminer.conf');
|
||||
if (existsSync(pluginDirSource) && existsSync(pluginConfigSource)) {
|
||||
if (
|
||||
existsSync(pluginDirSource) &&
|
||||
existsSync(pluginConfigSource) &&
|
||||
existsSync(joinPath(pluginDirSource, 'main.lua'))
|
||||
) {
|
||||
return { pluginDirSource, pluginConfigSource };
|
||||
}
|
||||
}
|
||||
@@ -45,7 +68,11 @@ export function detectInstalledFirstRunPlugin(
|
||||
deps?: { existsSync?: (candidate: string) => boolean },
|
||||
): boolean {
|
||||
const existsSync = deps?.existsSync ?? fs.existsSync;
|
||||
return existsSync(installPaths.pluginDir) && existsSync(installPaths.pluginConfigPath);
|
||||
return (
|
||||
existsSync(installPaths.pluginEntrypointPath) &&
|
||||
existsSync(installPaths.pluginDir) &&
|
||||
existsSync(installPaths.pluginConfigPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function installFirstRunPluginToDefaultLocation(options: {
|
||||
@@ -86,10 +113,15 @@ export function installFirstRunPluginToDefaultLocation(options: {
|
||||
|
||||
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
|
||||
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
|
||||
backupExistingPath(resolveLegacyPluginLoaderPath(installPaths));
|
||||
backupExistingPath(resolveLegacyPluginDebugLoaderPath(installPaths));
|
||||
backupExistingPath(installPaths.pluginDir);
|
||||
backupExistingPath(installPaths.pluginConfigPath);
|
||||
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
|
||||
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
|
||||
if (options.platform === 'win32') {
|
||||
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -21,6 +21,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
@@ -169,3 +171,35 @@ test('setup service marks cancelled when popup closes before completion', async
|
||||
assert.equal(cancelled.state.status, 'cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
test('setup service reflects detected Windows mpv shortcuts before preferences are persisted', 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({
|
||||
platform: 'win32',
|
||||
configDir,
|
||||
getYomitanDictionaryCount: async () => 0,
|
||||
detectPluginInstalled: () => false,
|
||||
installPlugin: async () => ({
|
||||
ok: true,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
message: 'ok',
|
||||
}),
|
||||
detectWindowsMpvShortcuts: async () => ({
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: true,
|
||||
}),
|
||||
onStateChanged: () => undefined,
|
||||
});
|
||||
|
||||
const snapshot = await service.ensureSetupStateInitialized();
|
||||
assert.equal(snapshot.windowsMpvShortcuts.startMenuEnabled, false);
|
||||
assert.equal(snapshot.windowsMpvShortcuts.desktopEnabled, true);
|
||||
assert.equal(snapshot.windowsMpvShortcuts.startMenuInstalled, false);
|
||||
assert.equal(snapshot.windowsMpvShortcuts.desktopInstalled, true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,16 +7,28 @@ import {
|
||||
readSetupState,
|
||||
writeSetupState,
|
||||
type SetupPluginInstallStatus,
|
||||
type SetupWindowsMpvShortcutInstallStatus,
|
||||
type SetupState,
|
||||
} from '../../shared/setup-state';
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
|
||||
export interface SetupWindowsMpvShortcutSnapshot {
|
||||
supported: boolean;
|
||||
startMenuEnabled: boolean;
|
||||
desktopEnabled: boolean;
|
||||
startMenuInstalled: boolean;
|
||||
desktopInstalled: boolean;
|
||||
status: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface SetupStatusSnapshot {
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||
pluginInstallPathSummary: string | null;
|
||||
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
|
||||
message: string | null;
|
||||
state: SetupState;
|
||||
}
|
||||
@@ -37,6 +49,10 @@ export interface FirstRunSetupService {
|
||||
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
|
||||
skipPluginInstall: () => Promise<SetupStatusSnapshot>;
|
||||
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
|
||||
configureWindowsMpvShortcuts: (preferences: {
|
||||
startMenuEnabled: boolean;
|
||||
desktopEnabled: boolean;
|
||||
}) => Promise<SetupStatusSnapshot>;
|
||||
isSetupCompleted: () => boolean;
|
||||
}
|
||||
|
||||
@@ -44,6 +60,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
||||
return Boolean(
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.launchMpv ||
|
||||
args.settings ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -95,15 +112,51 @@ function getPluginStatus(
|
||||
return 'optional';
|
||||
}
|
||||
|
||||
function getWindowsMpvShortcutStatus(
|
||||
state: SetupState,
|
||||
installed: { startMenuInstalled: boolean; desktopInstalled: boolean },
|
||||
): SetupWindowsMpvShortcutSnapshot['status'] {
|
||||
if (installed.startMenuInstalled || installed.desktopInstalled) return 'installed';
|
||||
if (state.windowsMpvShortcutLastStatus === 'skipped') return 'skipped';
|
||||
if (state.windowsMpvShortcutLastStatus === 'failed') return 'failed';
|
||||
return 'optional';
|
||||
}
|
||||
|
||||
function getEffectiveWindowsMpvShortcutPreferences(
|
||||
state: SetupState,
|
||||
installed: { startMenuInstalled: boolean; desktopInstalled: boolean },
|
||||
): { startMenuEnabled: boolean; desktopEnabled: boolean } {
|
||||
if (state.windowsMpvShortcutLastStatus === 'unknown') {
|
||||
return {
|
||||
startMenuEnabled: installed.startMenuInstalled,
|
||||
desktopEnabled: installed.desktopInstalled,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
startMenuEnabled: state.windowsMpvShortcutPreferences.startMenuEnabled,
|
||||
desktopEnabled: state.windowsMpvShortcutPreferences.desktopEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFirstRunSetupService(deps: {
|
||||
platform?: NodeJS.Platform;
|
||||
configDir: string;
|
||||
getYomitanDictionaryCount: () => Promise<number>;
|
||||
detectPluginInstalled: () => boolean | Promise<boolean>;
|
||||
installPlugin: () => Promise<PluginInstallResult>;
|
||||
detectWindowsMpvShortcuts?: () =>
|
||||
| { startMenuInstalled: boolean; desktopInstalled: boolean }
|
||||
| Promise<{ startMenuInstalled: boolean; desktopInstalled: boolean }>;
|
||||
applyWindowsMpvShortcuts?: (preferences: {
|
||||
startMenuEnabled: boolean;
|
||||
desktopEnabled: boolean;
|
||||
}) => Promise<{ ok: boolean; status: SetupWindowsMpvShortcutInstallStatus; message: string }>;
|
||||
onStateChanged?: (state: SetupState) => void;
|
||||
}): FirstRunSetupService {
|
||||
const setupStatePath = getSetupStatePath(deps.configDir);
|
||||
const configFilePaths = getDefaultConfigFilePaths(deps.configDir);
|
||||
const isWindows = (deps.platform ?? process.platform) === 'win32';
|
||||
let completed = false;
|
||||
|
||||
const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState();
|
||||
@@ -117,6 +170,17 @@ export function createFirstRunSetupService(deps: {
|
||||
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
|
||||
const dictionaryCount = await deps.getYomitanDictionaryCount();
|
||||
const pluginInstalled = await deps.detectPluginInstalled();
|
||||
const detectedWindowsMpvShortcuts = isWindows
|
||||
? await deps.detectWindowsMpvShortcuts?.()
|
||||
: undefined;
|
||||
const installedWindowsMpvShortcuts = {
|
||||
startMenuInstalled: detectedWindowsMpvShortcuts?.startMenuInstalled ?? false,
|
||||
desktopInstalled: detectedWindowsMpvShortcuts?.desktopInstalled ?? false,
|
||||
};
|
||||
const effectiveWindowsMpvShortcutPreferences = getEffectiveWindowsMpvShortcutPreferences(
|
||||
state,
|
||||
installedWindowsMpvShortcuts,
|
||||
);
|
||||
const configReady =
|
||||
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
|
||||
return {
|
||||
@@ -125,6 +189,15 @@ export function createFirstRunSetupService(deps: {
|
||||
canFinish: dictionaryCount >= 1,
|
||||
pluginStatus: getPluginStatus(state, pluginInstalled),
|
||||
pluginInstallPathSummary: state.pluginInstallPathSummary,
|
||||
windowsMpvShortcuts: {
|
||||
supported: isWindows,
|
||||
startMenuEnabled: effectiveWindowsMpvShortcutPreferences.startMenuEnabled,
|
||||
desktopEnabled: effectiveWindowsMpvShortcutPreferences.desktopEnabled,
|
||||
startMenuInstalled: installedWindowsMpvShortcuts.startMenuInstalled,
|
||||
desktopInstalled: installedWindowsMpvShortcuts.desktopInstalled,
|
||||
status: getWindowsMpvShortcutStatus(state, installedWindowsMpvShortcuts),
|
||||
message: null,
|
||||
},
|
||||
message,
|
||||
state,
|
||||
} satisfies SetupStatusSnapshot;
|
||||
@@ -220,6 +293,30 @@ export function createFirstRunSetupService(deps: {
|
||||
result.message,
|
||||
);
|
||||
},
|
||||
configureWindowsMpvShortcuts: async (preferences) => {
|
||||
const nextState = writeState({
|
||||
...readState(),
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled: preferences.startMenuEnabled,
|
||||
desktopEnabled: preferences.desktopEnabled,
|
||||
},
|
||||
});
|
||||
if (!isWindows || !deps.applyWindowsMpvShortcuts) {
|
||||
return refreshWithState(nextState, null);
|
||||
}
|
||||
const result = await deps.applyWindowsMpvShortcuts(preferences);
|
||||
return refreshWithState(
|
||||
writeState({
|
||||
...readState(),
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled: preferences.startMenuEnabled,
|
||||
desktopEnabled: preferences.desktopEnabled,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: result.status,
|
||||
}),
|
||||
result.message,
|
||||
);
|
||||
},
|
||||
isSetupCompleted: () => completed || isSetupCompleted(readState()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildFirstRunSetupHtml,
|
||||
createHandleFirstRunSetupNavigationHandler,
|
||||
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
||||
createOpenFirstRunSetupWindowHandler,
|
||||
parseFirstRunSetupSubmissionUrl,
|
||||
} from './first-run-setup-window';
|
||||
|
||||
@@ -14,6 +15,14 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
canFinish: false,
|
||||
pluginStatus: 'optional',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
message: 'Waiting for dictionaries',
|
||||
});
|
||||
|
||||
@@ -31,6 +40,14 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
||||
canFinish: true,
|
||||
pluginStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcuts: {
|
||||
supported: true,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: true,
|
||||
desktopInstalled: false,
|
||||
status: 'installed',
|
||||
},
|
||||
message: null,
|
||||
});
|
||||
|
||||
@@ -60,8 +77,8 @@ test('first-run setup navigation handler prevents default and dispatches action'
|
||||
const calls: string[] = [];
|
||||
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
|
||||
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
|
||||
handleAction: async (action) => {
|
||||
calls.push(action);
|
||||
handleAction: async (submission) => {
|
||||
calls.push(submission.action);
|
||||
},
|
||||
logError: (message) => calls.push(message),
|
||||
});
|
||||
@@ -75,3 +92,71 @@ test('first-run setup navigation handler prevents default and dispatches action'
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
|
||||
});
|
||||
|
||||
test('closing incomplete first-run setup quits app outside background mode', async () => {
|
||||
const calls: string[] = [];
|
||||
let closedHandler: (() => void) | undefined;
|
||||
const handler = createOpenFirstRunSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () =>
|
||||
({
|
||||
webContents: {
|
||||
on: () => {},
|
||||
},
|
||||
loadURL: async () => undefined,
|
||||
on: (event: 'closed', callback: () => void) => {
|
||||
if (event === 'closed') {
|
||||
closedHandler = callback;
|
||||
}
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
close: () => calls.push('close-window'),
|
||||
focus: () => {},
|
||||
}) as never,
|
||||
getSetupSnapshot: async () => ({
|
||||
configReady: false,
|
||||
dictionaryCount: 0,
|
||||
canFinish: false,
|
||||
pluginStatus: 'optional',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
message: null,
|
||||
}),
|
||||
buildSetupHtml: () => '<html></html>',
|
||||
parseSubmissionUrl: () => null,
|
||||
handleAction: async () => undefined,
|
||||
markSetupInProgress: async () => undefined,
|
||||
markSetupCancelled: async () => {
|
||||
calls.push('cancelled');
|
||||
},
|
||||
isSetupCompleted: () => false,
|
||||
shouldQuitWhenClosedIncomplete: () => true,
|
||||
quitApp: () => {
|
||||
calls.push('quit');
|
||||
},
|
||||
clearSetupWindow: () => {
|
||||
calls.push('clear');
|
||||
},
|
||||
setSetupWindow: () => {
|
||||
calls.push('set');
|
||||
},
|
||||
encodeURIComponent: (value) => value,
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
handler();
|
||||
if (typeof closedHandler !== 'function') {
|
||||
throw new Error('expected closed handler');
|
||||
}
|
||||
closedHandler();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(calls, ['set', 'cancelled', 'clear', 'quit']);
|
||||
});
|
||||
|
||||
@@ -16,17 +16,32 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
|
||||
|
||||
export type FirstRunSetupAction =
|
||||
| 'install-plugin'
|
||||
| 'configure-windows-mpv-shortcuts'
|
||||
| 'open-yomitan-settings'
|
||||
| 'refresh'
|
||||
| 'skip-plugin'
|
||||
| 'finish';
|
||||
|
||||
export interface FirstRunSetupSubmission {
|
||||
action: FirstRunSetupAction;
|
||||
startMenuEnabled?: boolean;
|
||||
desktopEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FirstRunSetupHtmlModel {
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||
pluginInstallPathSummary: string | null;
|
||||
windowsMpvShortcuts: {
|
||||
supported: boolean;
|
||||
startMenuEnabled: boolean;
|
||||
desktopEnabled: boolean;
|
||||
startMenuInstalled: boolean;
|
||||
desktopInstalled: boolean;
|
||||
status: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||
};
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
@@ -61,6 +76,43 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
: model.pluginStatus === 'skipped'
|
||||
? 'muted'
|
||||
: 'warn';
|
||||
const windowsShortcutLabel =
|
||||
model.windowsMpvShortcuts.status === 'installed'
|
||||
? 'Installed'
|
||||
: model.windowsMpvShortcuts.status === 'skipped'
|
||||
? 'Skipped'
|
||||
: model.windowsMpvShortcuts.status === 'failed'
|
||||
? 'Failed'
|
||||
: 'Optional';
|
||||
const windowsShortcutTone =
|
||||
model.windowsMpvShortcuts.status === 'installed'
|
||||
? 'ready'
|
||||
: model.windowsMpvShortcuts.status === 'failed'
|
||||
? 'danger'
|
||||
: model.windowsMpvShortcuts.status === 'skipped'
|
||||
? 'muted'
|
||||
: 'warn';
|
||||
const windowsShortcutCard = model.windowsMpvShortcuts.supported
|
||||
? `
|
||||
<div class="card block">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<strong>Windows mpv launcher</strong>
|
||||
<div class="meta">Create standalone \`SubMiner mpv\` shortcuts that run \`SubMiner.exe --launch-mpv\`.</div>
|
||||
<div class="meta">Installed: Start Menu ${model.windowsMpvShortcuts.startMenuInstalled ? 'yes' : 'no'}, Desktop ${model.windowsMpvShortcuts.desktopInstalled ? 'yes' : 'no'}</div>
|
||||
</div>
|
||||
${renderStatusBadge(windowsShortcutLabel, windowsShortcutTone)}
|
||||
</div>
|
||||
<form
|
||||
class="shortcut-form"
|
||||
onsubmit="event.preventDefault(); const params = new URLSearchParams({ action: 'configure-windows-mpv-shortcuts', startMenu: document.getElementById('shortcut-start-menu').checked ? '1' : '0', desktop: document.getElementById('shortcut-desktop').checked ? '1' : '0' }); window.location.href = 'subminer://first-run-setup?' + params.toString();"
|
||||
>
|
||||
<label><input id="shortcut-start-menu" type="checkbox" ${model.windowsMpvShortcuts.startMenuEnabled ? 'checked' : ''} /> Create Start Menu shortcut</label>
|
||||
<label><input id="shortcut-desktop" type="checkbox" ${model.windowsMpvShortcuts.desktopEnabled ? 'checked' : ''} /> Create Desktop shortcut</label>
|
||||
<button type="submit">Apply mpv launcher shortcuts</button>
|
||||
</form>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
@@ -109,10 +161,30 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.card.block {
|
||||
display: block;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.shortcut-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
label {
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -192,6 +264,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
model.dictionaryCount >= 1 ? 'ready' : 'warn',
|
||||
)}
|
||||
</div>
|
||||
${windowsShortcutCard}
|
||||
<div class="actions">
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
||||
@@ -208,7 +281,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
|
||||
export function parseFirstRunSetupSubmissionUrl(
|
||||
rawUrl: string,
|
||||
): { action: FirstRunSetupAction } | null {
|
||||
): FirstRunSetupSubmission | null {
|
||||
if (!rawUrl.startsWith('subminer://first-run-setup')) {
|
||||
return null;
|
||||
}
|
||||
@@ -216,6 +289,7 @@ export function parseFirstRunSetupSubmissionUrl(
|
||||
const action = parsed.searchParams.get('action');
|
||||
if (
|
||||
action !== 'install-plugin' &&
|
||||
action !== 'configure-windows-mpv-shortcuts' &&
|
||||
action !== 'open-yomitan-settings' &&
|
||||
action !== 'refresh' &&
|
||||
action !== 'skip-plugin' &&
|
||||
@@ -223,6 +297,13 @@ export function parseFirstRunSetupSubmissionUrl(
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (action === 'configure-windows-mpv-shortcuts') {
|
||||
return {
|
||||
action,
|
||||
startMenuEnabled: parsed.searchParams.get('startMenu') === '1',
|
||||
desktopEnabled: parsed.searchParams.get('desktop') === '1',
|
||||
};
|
||||
}
|
||||
return { action };
|
||||
}
|
||||
|
||||
@@ -238,15 +319,15 @@ export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
|
||||
}
|
||||
|
||||
export function createHandleFirstRunSetupNavigationHandler(deps: {
|
||||
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
|
||||
handleAction: (action: FirstRunSetupAction) => Promise<unknown>;
|
||||
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
|
||||
handleAction: (submission: FirstRunSetupSubmission) => Promise<unknown>;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return (params: { url: string; preventDefault: () => void }): boolean => {
|
||||
const submission = deps.parseSubmissionUrl(params.url);
|
||||
if (!submission) return false;
|
||||
params.preventDefault();
|
||||
void deps.handleAction(submission.action).catch((error) => {
|
||||
void deps.handleAction(submission).catch((error) => {
|
||||
deps.logError('Failed handling first-run setup action', error);
|
||||
});
|
||||
return true;
|
||||
@@ -260,11 +341,13 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
createSetupWindow: () => TWindow;
|
||||
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
|
||||
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
|
||||
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
|
||||
handleAction: (action: FirstRunSetupAction) => Promise<{ closeWindow?: boolean } | void>;
|
||||
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
|
||||
handleAction: (submission: FirstRunSetupSubmission) => Promise<{ closeWindow?: boolean } | void>;
|
||||
markSetupInProgress: () => Promise<unknown>;
|
||||
markSetupCancelled: () => Promise<unknown>;
|
||||
isSetupCompleted: () => boolean;
|
||||
shouldQuitWhenClosedIncomplete: () => boolean;
|
||||
quitApp: () => void;
|
||||
clearSetupWindow: () => void;
|
||||
setSetupWindow: (window: TWindow) => void;
|
||||
encodeURIComponent: (value: string) => string;
|
||||
@@ -286,8 +369,8 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
|
||||
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
|
||||
parseSubmissionUrl: deps.parseSubmissionUrl,
|
||||
handleAction: async (action) => {
|
||||
const result = await deps.handleAction(action);
|
||||
handleAction: async (submission) => {
|
||||
const result = await deps.handleAction(submission);
|
||||
if (result?.closeWindow) {
|
||||
if (!setupWindow.isDestroyed()) {
|
||||
setupWindow.close();
|
||||
@@ -313,12 +396,16 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
});
|
||||
|
||||
setupWindow.on('closed', () => {
|
||||
if (!deps.isSetupCompleted()) {
|
||||
const setupCompleted = deps.isSetupCompleted();
|
||||
if (!setupCompleted) {
|
||||
void deps.markSetupCancelled().catch((error) => {
|
||||
deps.logError('Failed marking first-run setup cancelled', error);
|
||||
});
|
||||
}
|
||||
deps.clearSetupWindow();
|
||||
if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) {
|
||||
deps.quitApp();
|
||||
}
|
||||
});
|
||||
|
||||
void deps
|
||||
|
||||
@@ -7,6 +7,7 @@ test('initial args handler no-ops without initial args', () => {
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => null,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => false,
|
||||
@@ -26,6 +27,7 @@ test('initial args handler ensures tray in background mode', () => {
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
ensureTray: () => {
|
||||
ensuredTray = true;
|
||||
},
|
||||
@@ -46,6 +48,7 @@ test('initial args handler auto-connects mpv when needed', () => {
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
@@ -71,6 +74,7 @@ test('initial args handler forwards args to cli handler', () => {
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => false,
|
||||
@@ -84,3 +88,23 @@ test('initial args handler forwards args to cli handler', () => {
|
||||
handleInitialArgs();
|
||||
assert.deepEqual(seenSources, ['initial']);
|
||||
});
|
||||
|
||||
test('initial args handler can ensure tray outside background mode when requested', () => {
|
||||
let ensuredTray = false;
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => true,
|
||||
ensureTray: () => {
|
||||
ensuredTray = true;
|
||||
},
|
||||
isTexthookerOnlyMode: () => true,
|
||||
hasImmersionTracker: () => false,
|
||||
getMpvClient: () => null,
|
||||
logInfo: () => {},
|
||||
handleCliCommand: () => {},
|
||||
});
|
||||
|
||||
handleInitialArgs();
|
||||
assert.equal(ensuredTray, true);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ type MpvClientLike = {
|
||||
export function createHandleInitialArgsHandler(deps: {
|
||||
getInitialArgs: () => CliArgs | null;
|
||||
isBackgroundMode: () => boolean;
|
||||
shouldEnsureTrayOnStartup: () => boolean;
|
||||
ensureTray: () => void;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
hasImmersionTracker: () => boolean;
|
||||
@@ -19,7 +20,7 @@ export function createHandleInitialArgsHandler(deps: {
|
||||
const initialArgs = deps.getInitialArgs();
|
||||
if (!initialArgs) return;
|
||||
|
||||
if (deps.isBackgroundMode()) {
|
||||
if (deps.isBackgroundMode() || deps.shouldEnsureTrayOnStartup()) {
|
||||
deps.ensureTray();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
||||
const deps = createBuildHandleInitialArgsMainDepsHandler({
|
||||
getInitialArgs: () => args,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
ensureTray: () => calls.push('ensure-tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
@@ -19,6 +20,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
||||
|
||||
assert.equal(deps.getInitialArgs(), args);
|
||||
assert.equal(deps.isBackgroundMode(), true);
|
||||
assert.equal(deps.shouldEnsureTrayOnStartup(), false);
|
||||
assert.equal(deps.isTexthookerOnlyMode(), false);
|
||||
assert.equal(deps.hasImmersionTracker(), true);
|
||||
assert.equal(deps.getMpvClient(), mpvClient);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CliArgs } from '../../cli/args';
|
||||
export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
||||
getInitialArgs: () => CliArgs | null;
|
||||
isBackgroundMode: () => boolean;
|
||||
shouldEnsureTrayOnStartup: () => boolean;
|
||||
ensureTray: () => void;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
hasImmersionTracker: () => boolean;
|
||||
@@ -13,6 +14,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
||||
return () => ({
|
||||
getInitialArgs: () => deps.getInitialArgs(),
|
||||
isBackgroundMode: () => deps.isBackgroundMode(),
|
||||
shouldEnsureTrayOnStartup: () => deps.shouldEnsureTrayOnStartup(),
|
||||
ensureTray: () => deps.ensureTray(),
|
||||
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
||||
hasImmersionTracker: () => deps.hasImmersionTracker(),
|
||||
|
||||
@@ -7,6 +7,7 @@ test('initial args runtime handler composes main deps and runs initial command f
|
||||
const handleInitialArgs = createInitialArgsRuntimeHandler({
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
ensureTray: () => calls.push('tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
|
||||
@@ -13,8 +13,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
||||
calls.push(`registered:${registered}`);
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
isMacOSPlatform: () => true,
|
||||
isTrackedMpvWindowFocused: () => false,
|
||||
isOverlayShortcutContextActive: () => false,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openJimaku: () => calls.push('jimaku'),
|
||||
@@ -42,8 +41,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
||||
})();
|
||||
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), true);
|
||||
assert.equal(deps.isMacOSPlatform(), true);
|
||||
assert.equal(deps.isTrackedMpvWindowFocused(), false);
|
||||
assert.equal(deps.isOverlayShortcutContextActive?.(), false);
|
||||
assert.equal(deps.getShortcutsRegistered(), false);
|
||||
deps.setShortcutsRegistered(true);
|
||||
assert.equal(shortcutsRegistered, true);
|
||||
|
||||
@@ -8,8 +8,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
|
||||
getShortcutsRegistered: () => deps.getShortcutsRegistered(),
|
||||
setShortcutsRegistered: (registered: boolean) => deps.setShortcutsRegistered(registered),
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
isMacOSPlatform: () => deps.isMacOSPlatform(),
|
||||
isTrackedMpvWindowFocused: () => deps.isTrackedMpvWindowFocused(),
|
||||
isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true,
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
openJimaku: () => deps.openJimaku(),
|
||||
|
||||
@@ -25,6 +25,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
|
||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||
isMacOSPlatform: () => true,
|
||||
isWindowsPlatform: () => false,
|
||||
showOverlayLoadingOsd: () => calls.push('overlay-loading-osd'),
|
||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 20, height: 20 }),
|
||||
})();
|
||||
@@ -39,6 +40,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
deps.enforceOverlayLayerOrder();
|
||||
deps.syncOverlayShortcuts();
|
||||
assert.equal(deps.isMacOSPlatform(), true);
|
||||
assert.equal(deps.isWindowsPlatform(), false);
|
||||
deps.showOverlayLoadingOsd('Overlay loading...');
|
||||
assert.deepEqual(deps.resolveFallbackBounds(), { x: 0, y: 0, width: 20, height: 20 });
|
||||
assert.equal(trackerNotReadyWarningShown, true);
|
||||
|
||||
@@ -18,6 +18,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||
isMacOSPlatform: () => deps.isMacOSPlatform(),
|
||||
isWindowsPlatform: () => deps.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
buildTrayMenuTemplateRuntime: (handlers) => {
|
||||
handlers.openOverlay();
|
||||
handlers.openFirstRunSetup();
|
||||
handlers.openWindowsMpvLauncherSetup();
|
||||
handlers.openYomitanSettings();
|
||||
handlers.openRuntimeOptions();
|
||||
handlers.openJellyfinSetup();
|
||||
@@ -58,6 +59,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
@@ -71,6 +73,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'init',
|
||||
'visible:true',
|
||||
'setup',
|
||||
'setup',
|
||||
'yomitan',
|
||||
'runtime-options',
|
||||
'jellyfin',
|
||||
|
||||
@@ -31,6 +31,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
@@ -42,6 +44,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
@@ -60,6 +63,10 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
deps.openFirstRunSetupWindow();
|
||||
},
|
||||
showFirstRunSetup: deps.showFirstRunSetup(),
|
||||
openWindowsMpvLauncherSetup: () => {
|
||||
deps.openFirstRunSetupWindow();
|
||||
},
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
|
||||
openYomitanSettings: () => {
|
||||
deps.openYomitanSettings();
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
@@ -38,6 +39,8 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openOverlay: () => calls.push('open-overlay'),
|
||||
openFirstRunSetup: () => calls.push('open-setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openRuntimeOptions: () => calls.push('open-runtime-options'),
|
||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||
|
||||
@@ -30,6 +30,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
@@ -41,6 +43,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
@@ -54,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
|
||||
showFirstRunSetup: deps.showFirstRunSetup,
|
||||
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
||||
|
||||
@@ -29,6 +29,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
},
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => {},
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
|
||||
@@ -32,6 +32,8 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
openOverlay: () => calls.push('overlay'),
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptions: () => calls.push('runtime'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
@@ -39,10 +41,10 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
assert.equal(template.length, 8);
|
||||
assert.equal(template.length, 9);
|
||||
template[0]!.click?.();
|
||||
template[6]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[7]!.click?.();
|
||||
template[7]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[8]!.click?.();
|
||||
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
|
||||
});
|
||||
|
||||
@@ -51,6 +53,8 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
openOverlay: () => undefined,
|
||||
openFirstRunSetup: () => undefined,
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
@@ -61,4 +65,5 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
.filter(Boolean);
|
||||
|
||||
assert.equal(labels.includes('Complete Setup'), false);
|
||||
assert.equal(labels.includes('Manage Windows mpv launcher'), false);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ export type TrayMenuActionHandlers = {
|
||||
openOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
@@ -58,6 +60,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(handlers.showWindowsMpvLauncherSetup
|
||||
? [
|
||||
{
|
||||
label: 'Manage Windows mpv launcher',
|
||||
click: handlers.openWindowsMpvLauncherSetup,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Open Yomitan Settings',
|
||||
click: handlers.openYomitanSettings,
|
||||
|
||||
106
src/main/runtime/windows-mpv-launch.test.ts
Normal file
106
src/main/runtime/windows-mpv-launch.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildWindowsMpvLaunchArgs,
|
||||
launchWindowsMpv,
|
||||
resolveWindowsMpvPath,
|
||||
type WindowsMpvLaunchDeps,
|
||||
} from './windows-mpv-launch';
|
||||
|
||||
function createDeps(overrides: Partial<WindowsMpvLaunchDeps> = {}): WindowsMpvLaunchDeps {
|
||||
return {
|
||||
getEnv: () => undefined,
|
||||
runWhere: () => ({ status: 1, stdout: '' }),
|
||||
fileExists: () => false,
|
||||
spawnDetached: () => undefined,
|
||||
showError: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('resolveWindowsMpvPath prefers SUBMINER_MPV_PATH', () => {
|
||||
const resolved = resolveWindowsMpvPath(
|
||||
createDeps({
|
||||
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
|
||||
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
|
||||
});
|
||||
|
||||
test('resolveWindowsMpvPath falls back to where.exe output', () => {
|
||||
const resolved = resolveWindowsMpvPath(
|
||||
createDeps({
|
||||
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\nC:\\other\\mpv.exe\r\n' }),
|
||||
fileExists: (candidate) => candidate === 'C:\\tools\\mpv.exe',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(resolved, 'C:\\tools\\mpv.exe');
|
||||
});
|
||||
|
||||
test('buildWindowsMpvLaunchArgs keeps pseudo-gui profile and targets', () => {
|
||||
assert.deepEqual(buildWindowsMpvLaunchArgs(['C:\\a.mkv', 'C:\\b.mkv']), [
|
||||
'--player-operation-mode=pseudo-gui',
|
||||
'--profile=subminer',
|
||||
'C:\\a.mkv',
|
||||
'C:\\b.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv reports missing mpv path', () => {
|
||||
const errors: string[] = [];
|
||||
const result = launchWindowsMpv(
|
||||
[],
|
||||
createDeps({
|
||||
showError: (_title, content) => errors.push(content),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.mpvPath, '');
|
||||
assert.match(errors[0] ?? '', /Could not find mpv\.exe/i);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv spawns detached mpv with targets', () => {
|
||||
const calls: string[] = [];
|
||||
const result = launchWindowsMpv(
|
||||
['C:\\video.mkv'],
|
||||
createDeps({
|
||||
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
|
||||
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
|
||||
spawnDetached: (command, args) => {
|
||||
calls.push(command);
|
||||
calls.push(args.join('|'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
|
||||
assert.deepEqual(calls, [
|
||||
'C:\\mpv\\mpv.exe',
|
||||
'--player-operation-mode=pseudo-gui|--profile=subminer|C:\\video.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv reports spawn failures with path context', () => {
|
||||
const errors: string[] = [];
|
||||
const result = launchWindowsMpv(
|
||||
[],
|
||||
createDeps({
|
||||
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
|
||||
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
|
||||
spawnDetached: () => {
|
||||
throw new Error('spawn failed');
|
||||
},
|
||||
showError: (_title, content) => errors.push(content),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
|
||||
assert.match(errors[0] ?? '', /Failed to launch mpv/i);
|
||||
assert.match(errors[0] ?? '', /C:\\mpv\\mpv\.exe/i);
|
||||
});
|
||||
100
src/main/runtime/windows-mpv-launch.ts
Normal file
100
src/main/runtime/windows-mpv-launch.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import fs from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
export interface WindowsMpvLaunchDeps {
|
||||
getEnv: (name: string) => string | undefined;
|
||||
runWhere: () => { status: number | null; stdout: string; error?: Error };
|
||||
fileExists: (candidate: string) => boolean;
|
||||
spawnDetached: (command: string, args: string[]) => void;
|
||||
showError: (title: string, content: string) => void;
|
||||
}
|
||||
|
||||
function normalizeCandidate(candidate: string | undefined): string {
|
||||
return typeof candidate === 'string' ? candidate.trim() : '';
|
||||
}
|
||||
|
||||
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
|
||||
const envPath = normalizeCandidate(deps.getEnv('SUBMINER_MPV_PATH'));
|
||||
if (envPath && deps.fileExists(envPath)) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
const whereResult = deps.runWhere();
|
||||
if (whereResult.status === 0) {
|
||||
const firstPath = whereResult.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && deps.fileExists(line));
|
||||
if (firstPath) {
|
||||
return firstPath;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildWindowsMpvLaunchArgs(targets: string[]): string[] {
|
||||
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...targets];
|
||||
}
|
||||
|
||||
export function launchWindowsMpv(
|
||||
targets: string[],
|
||||
deps: WindowsMpvLaunchDeps,
|
||||
): { ok: boolean; mpvPath: string } {
|
||||
const mpvPath = resolveWindowsMpvPath(deps);
|
||||
if (!mpvPath) {
|
||||
deps.showError(
|
||||
'SubMiner mpv launcher',
|
||||
'Could not find mpv.exe. Install mpv and add it to PATH, or set SUBMINER_MPV_PATH.',
|
||||
);
|
||||
return { ok: false, mpvPath: '' };
|
||||
}
|
||||
|
||||
try {
|
||||
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets));
|
||||
return { ok: true, mpvPath };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.showError('SubMiner mpv launcher', `Failed to launch mpv.\nPath: ${mpvPath}\n${message}`);
|
||||
return { ok: false, mpvPath };
|
||||
}
|
||||
}
|
||||
|
||||
export function createWindowsMpvLaunchDeps(options: {
|
||||
getEnv?: (name: string) => string | undefined;
|
||||
fileExists?: (candidate: string) => boolean;
|
||||
showError: (title: string, content: string) => void;
|
||||
}): WindowsMpvLaunchDeps {
|
||||
return {
|
||||
getEnv: options.getEnv ?? ((name) => process.env[name]),
|
||||
runWhere: () => {
|
||||
const result = spawnSync('where.exe', ['mpv.exe'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout ?? '',
|
||||
error: result.error ?? undefined,
|
||||
};
|
||||
},
|
||||
fileExists:
|
||||
options.fileExists ??
|
||||
((candidate) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
spawnDetached: (command, args) => {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
},
|
||||
showError: options.showError,
|
||||
};
|
||||
}
|
||||
130
src/main/runtime/windows-mpv-shortcuts.test.ts
Normal file
130
src/main/runtime/windows-mpv-shortcuts.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyWindowsMpvShortcuts,
|
||||
buildWindowsMpvShortcutDetails,
|
||||
detectWindowsMpvShortcuts,
|
||||
resolveWindowsMpvShortcutPaths,
|
||||
resolveWindowsStartMenuProgramsDir,
|
||||
} from './windows-mpv-shortcuts';
|
||||
|
||||
test('resolveWindowsStartMenuProgramsDir derives Programs folder from APPDATA', () => {
|
||||
assert.equal(
|
||||
resolveWindowsStartMenuProgramsDir('C:\\Users\\tester\\AppData\\Roaming'),
|
||||
'C:\\Users\\tester\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveWindowsMpvShortcutPaths builds start menu and desktop lnk paths', () => {
|
||||
const paths = resolveWindowsMpvShortcutPaths({
|
||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||
desktopDir: 'C:\\Users\\tester\\Desktop',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
paths.startMenuPath,
|
||||
'C:\\Users\\tester\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\SubMiner mpv.lnk',
|
||||
);
|
||||
assert.equal(paths.desktopPath, 'C:\\Users\\tester\\Desktop\\SubMiner mpv.lnk');
|
||||
});
|
||||
|
||||
test('buildWindowsMpvShortcutDetails targets SubMiner.exe with --launch-mpv', () => {
|
||||
assert.deepEqual(buildWindowsMpvShortcutDetails('C:\\Apps\\SubMiner\\SubMiner.exe'), {
|
||||
target: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||
args: '--launch-mpv',
|
||||
cwd: 'C:\\Apps\\SubMiner',
|
||||
description: 'Launch mpv with the SubMiner profile',
|
||||
icon: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||
iconIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('detectWindowsMpvShortcuts reflects existing shortcuts', () => {
|
||||
const detected = detectWindowsMpvShortcuts(
|
||||
{
|
||||
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
|
||||
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
|
||||
},
|
||||
(candidate) => candidate === 'C:\\Desktop\\SubMiner mpv.lnk',
|
||||
);
|
||||
|
||||
assert.deepEqual(detected, {
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('applyWindowsMpvShortcuts creates enabled shortcuts and removes disabled ones', () => {
|
||||
const writes: string[] = [];
|
||||
const removes: string[] = [];
|
||||
const result = applyWindowsMpvShortcuts({
|
||||
preferences: {
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: false,
|
||||
},
|
||||
paths: {
|
||||
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
|
||||
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
|
||||
},
|
||||
exePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||
writeShortcutLink: (shortcutPath, _operation, details) => {
|
||||
writes.push(`${shortcutPath}|${details.target}|${details.args}`);
|
||||
return true;
|
||||
},
|
||||
rmSync: (candidate) => {
|
||||
removes.push(candidate);
|
||||
},
|
||||
mkdirSync: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.status, 'installed');
|
||||
assert.deepEqual(writes, [
|
||||
'C:\\Programs\\SubMiner mpv.lnk|C:\\Apps\\SubMiner\\SubMiner.exe|--launch-mpv',
|
||||
]);
|
||||
assert.deepEqual(removes, ['C:\\Desktop\\SubMiner mpv.lnk']);
|
||||
});
|
||||
|
||||
test('applyWindowsMpvShortcuts returns skipped when both shortcuts are disabled', () => {
|
||||
const removes: string[] = [];
|
||||
const result = applyWindowsMpvShortcuts({
|
||||
preferences: {
|
||||
startMenuEnabled: false,
|
||||
desktopEnabled: false,
|
||||
},
|
||||
paths: {
|
||||
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
|
||||
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
|
||||
},
|
||||
exePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||
writeShortcutLink: () => true,
|
||||
rmSync: (candidate) => {
|
||||
removes.push(candidate);
|
||||
},
|
||||
mkdirSync: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.status, 'skipped');
|
||||
assert.deepEqual(removes, ['C:\\Programs\\SubMiner mpv.lnk', 'C:\\Desktop\\SubMiner mpv.lnk']);
|
||||
});
|
||||
|
||||
test('applyWindowsMpvShortcuts reports write failures', () => {
|
||||
const result = applyWindowsMpvShortcuts({
|
||||
preferences: {
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
},
|
||||
paths: {
|
||||
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
|
||||
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
|
||||
},
|
||||
exePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||
writeShortcutLink: (shortcutPath) => shortcutPath.endsWith('Desktop\\SubMiner mpv.lnk'),
|
||||
mkdirSync: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, 'failed');
|
||||
assert.match(result.message, /C:\\Programs\\SubMiner mpv\.lnk/);
|
||||
});
|
||||
117
src/main/runtime/windows-mpv-shortcuts.ts
Normal file
117
src/main/runtime/windows-mpv-shortcuts.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const WINDOWS_MPV_SHORTCUT_NAME = 'SubMiner mpv.lnk';
|
||||
|
||||
export interface WindowsMpvShortcutPaths {
|
||||
startMenuPath: string;
|
||||
desktopPath: string;
|
||||
}
|
||||
|
||||
export interface WindowsShortcutLinkDetails {
|
||||
target: string;
|
||||
args?: string;
|
||||
cwd?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
iconIndex?: number;
|
||||
}
|
||||
|
||||
export interface WindowsMpvShortcutInstallResult {
|
||||
ok: boolean;
|
||||
status: 'installed' | 'skipped' | 'failed';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function resolveWindowsStartMenuProgramsDir(appDataDir: string): string {
|
||||
return path.join(appDataDir, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
|
||||
}
|
||||
|
||||
export function resolveWindowsMpvShortcutPaths(options: {
|
||||
appDataDir: string;
|
||||
desktopDir: string;
|
||||
}): WindowsMpvShortcutPaths {
|
||||
return {
|
||||
startMenuPath: path.join(resolveWindowsStartMenuProgramsDir(options.appDataDir), WINDOWS_MPV_SHORTCUT_NAME),
|
||||
desktopPath: path.join(options.desktopDir, WINDOWS_MPV_SHORTCUT_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
export function detectWindowsMpvShortcuts(
|
||||
paths: WindowsMpvShortcutPaths,
|
||||
existsSync: (candidate: string) => boolean = fs.existsSync,
|
||||
): { startMenuInstalled: boolean; desktopInstalled: boolean } {
|
||||
return {
|
||||
startMenuInstalled: existsSync(paths.startMenuPath),
|
||||
desktopInstalled: existsSync(paths.desktopPath),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWindowsMpvShortcutDetails(exePath: string): WindowsShortcutLinkDetails {
|
||||
return {
|
||||
target: exePath,
|
||||
args: '--launch-mpv',
|
||||
cwd: path.dirname(exePath),
|
||||
description: 'Launch mpv with the SubMiner profile',
|
||||
icon: exePath,
|
||||
iconIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyWindowsMpvShortcuts(options: {
|
||||
preferences: { startMenuEnabled: boolean; desktopEnabled: boolean };
|
||||
paths: WindowsMpvShortcutPaths;
|
||||
exePath: string;
|
||||
writeShortcutLink: (
|
||||
shortcutPath: string,
|
||||
operation: 'create' | 'update' | 'replace',
|
||||
details: WindowsShortcutLinkDetails,
|
||||
) => boolean;
|
||||
rmSync?: (candidate: string, options: { force: true }) => void;
|
||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||
}): WindowsMpvShortcutInstallResult {
|
||||
const rmSync = options.rmSync ?? fs.rmSync;
|
||||
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
||||
const details = buildWindowsMpvShortcutDetails(options.exePath);
|
||||
const failures: string[] = [];
|
||||
|
||||
const ensureShortcut = (shortcutPath: string): void => {
|
||||
mkdirSync(path.dirname(shortcutPath), { recursive: true });
|
||||
const ok = options.writeShortcutLink(shortcutPath, 'create', details);
|
||||
if (!ok) {
|
||||
failures.push(shortcutPath);
|
||||
}
|
||||
};
|
||||
|
||||
const removeShortcut = (shortcutPath: string): void => {
|
||||
rmSync(shortcutPath, { force: true });
|
||||
};
|
||||
|
||||
if (options.preferences.startMenuEnabled) ensureShortcut(options.paths.startMenuPath);
|
||||
else removeShortcut(options.paths.startMenuPath);
|
||||
|
||||
if (options.preferences.desktopEnabled) ensureShortcut(options.paths.desktopPath);
|
||||
else removeShortcut(options.paths.desktopPath);
|
||||
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
message: `Failed to create Windows mpv shortcuts: ${failures.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!options.preferences.startMenuEnabled && !options.preferences.desktopEnabled) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 'skipped',
|
||||
message: 'Disabled Windows mpv shortcuts.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 'installed',
|
||||
message: 'Updated Windows mpv shortcuts.',
|
||||
};
|
||||
}
|
||||
66
src/main/runtime/yomitan-anki-server.test.ts
Normal file
66
src/main/runtime/yomitan-anki-server.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { AnkiConnectConfig } from '../../types';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
} from './yomitan-anki-server';
|
||||
|
||||
function createConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
url: 'http://127.0.0.1:8765',
|
||||
proxy: {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8766,
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
...overrides,
|
||||
} as AnkiConnectConfig;
|
||||
}
|
||||
|
||||
test('prefers upstream AnkiConnect when SubMiner integration is disabled', () => {
|
||||
const config = createConfig({
|
||||
enabled: false,
|
||||
proxy: {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8766,
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:8765');
|
||||
assert.equal(shouldForceOverrideYomitanAnkiServer(config), false);
|
||||
});
|
||||
|
||||
test('prefers SubMiner proxy when SubMiner integration and proxy are enabled', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
proxy: {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 9988,
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:9988');
|
||||
assert.equal(shouldForceOverrideYomitanAnkiServer(config), true);
|
||||
});
|
||||
|
||||
test('falls back to upstream AnkiConnect when proxy transport is disabled', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
proxy: {
|
||||
enabled: false,
|
||||
host: '127.0.0.1',
|
||||
port: 8766,
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:8765');
|
||||
assert.equal(shouldForceOverrideYomitanAnkiServer(config), false);
|
||||
});
|
||||
15
src/main/runtime/yomitan-anki-server.ts
Normal file
15
src/main/runtime/yomitan-anki-server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { AnkiConnectConfig } from '../../types';
|
||||
|
||||
export function getPreferredYomitanAnkiServerUrl(config: AnkiConnectConfig): string {
|
||||
if (config.enabled === true && config.proxy?.enabled === true) {
|
||||
const host = config.proxy.host || '127.0.0.1';
|
||||
const port = config.proxy.port || 8766;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
return config.url || 'http://127.0.0.1:8765';
|
||||
}
|
||||
|
||||
export function shouldForceOverrideYomitanAnkiServer(config: AnkiConnectConfig): boolean {
|
||||
return config.enabled === true && config.proxy?.enabled === true;
|
||||
}
|
||||
83
src/renderer/positioning/position-state.test.ts
Normal file
83
src/renderer/positioning/position-state.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createInMemorySubtitlePositionController } from './position-state.js';
|
||||
|
||||
function withWindow<T>(windowValue: unknown, callback: () => T): T {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: windowValue,
|
||||
});
|
||||
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: previousWindow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(subtitleHeight: number) {
|
||||
return {
|
||||
dom: {
|
||||
subtitleContainer: {
|
||||
style: {
|
||||
position: '',
|
||||
left: '',
|
||||
top: '',
|
||||
right: '',
|
||||
transform: '',
|
||||
marginBottom: '',
|
||||
},
|
||||
offsetHeight: subtitleHeight,
|
||||
},
|
||||
},
|
||||
state: {
|
||||
currentYPercent: null,
|
||||
persistedSubtitlePosition: { yPercent: 10 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('subtitle position clamp keeps tall subtitles inside the overlay viewport', () => {
|
||||
withWindow(
|
||||
{
|
||||
innerHeight: 1000,
|
||||
electronAPI: {
|
||||
saveSubtitlePosition: () => {},
|
||||
},
|
||||
},
|
||||
() => {
|
||||
const ctx = createContext(300);
|
||||
const controller = createInMemorySubtitlePositionController(ctx as never);
|
||||
|
||||
controller.applyYPercent(80);
|
||||
|
||||
assert.equal(ctx.state.currentYPercent, 68.8);
|
||||
assert.equal(ctx.dom.subtitleContainer.style.marginBottom, '688px');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle position clamp falls back to the minimum safe inset when subtitle is taller than the viewport', () => {
|
||||
withWindow(
|
||||
{
|
||||
innerHeight: 200,
|
||||
electronAPI: {
|
||||
saveSubtitlePosition: () => {},
|
||||
},
|
||||
},
|
||||
() => {
|
||||
const ctx = createContext(260);
|
||||
const controller = createInMemorySubtitlePositionController(ctx as never);
|
||||
|
||||
controller.applyYPercent(80);
|
||||
|
||||
assert.equal(ctx.state.currentYPercent, 6);
|
||||
assert.equal(ctx.dom.subtitleContainer.style.marginBottom, '12px');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { RendererContext } from '../context';
|
||||
|
||||
const PREFERRED_Y_PERCENT_MIN = 2;
|
||||
const PREFERRED_Y_PERCENT_MAX = 80;
|
||||
const SUBTITLE_EDGE_PADDING_PX = 12;
|
||||
|
||||
export type SubtitlePositionController = {
|
||||
applyStoredSubtitlePosition: (position: SubtitlePosition | null, source: string) => void;
|
||||
@@ -11,8 +12,47 @@ export type SubtitlePositionController = {
|
||||
persistSubtitlePositionPatch: (patch: Partial<SubtitlePosition>) => void;
|
||||
};
|
||||
|
||||
function clampYPercent(yPercent: number): number {
|
||||
return Math.max(PREFERRED_Y_PERCENT_MIN, Math.min(PREFERRED_Y_PERCENT_MAX, yPercent));
|
||||
function getViewportHeight(): number {
|
||||
return Math.max(window.innerHeight || 0, 1);
|
||||
}
|
||||
|
||||
function getSubtitleContainerHeight(ctx: RendererContext): number {
|
||||
const container = ctx.dom.subtitleContainer as HTMLElement & {
|
||||
offsetHeight?: number;
|
||||
getBoundingClientRect?: () => { height?: number };
|
||||
};
|
||||
if (typeof container.offsetHeight === 'number' && Number.isFinite(container.offsetHeight)) {
|
||||
return Math.max(container.offsetHeight, 0);
|
||||
}
|
||||
if (typeof container.getBoundingClientRect === 'function') {
|
||||
const height = container.getBoundingClientRect().height;
|
||||
if (typeof height === 'number' && Number.isFinite(height)) {
|
||||
return Math.max(height, 0);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function resolveYPercentClampRange(ctx: RendererContext): { min: number; max: number } {
|
||||
const viewportHeight = getViewportHeight();
|
||||
const subtitleHeight = getSubtitleContainerHeight(ctx);
|
||||
const minPercent = Math.max(PREFERRED_Y_PERCENT_MIN, (SUBTITLE_EDGE_PADDING_PX / viewportHeight) * 100);
|
||||
const maxMarginBottomPx = Math.max(
|
||||
SUBTITLE_EDGE_PADDING_PX,
|
||||
viewportHeight - subtitleHeight - SUBTITLE_EDGE_PADDING_PX,
|
||||
);
|
||||
const maxPercent = Math.min(PREFERRED_Y_PERCENT_MAX, (maxMarginBottomPx / viewportHeight) * 100);
|
||||
|
||||
if (maxPercent < minPercent) {
|
||||
return { min: minPercent, max: minPercent };
|
||||
}
|
||||
|
||||
return { min: minPercent, max: maxPercent };
|
||||
}
|
||||
|
||||
function clampYPercent(ctx: RendererContext, yPercent: number): number {
|
||||
const { min, max } = resolveYPercentClampRange(ctx);
|
||||
return Math.max(min, Math.min(max, yPercent));
|
||||
}
|
||||
|
||||
function getPersistedYPercent(ctx: RendererContext, position: SubtitlePosition | null): number {
|
||||
@@ -53,14 +93,14 @@ export function createInMemorySubtitlePositionController(
|
||||
}
|
||||
|
||||
const marginBottom = parseFloat(ctx.dom.subtitleContainer.style.marginBottom) || 60;
|
||||
ctx.state.currentYPercent = clampYPercent((marginBottom / window.innerHeight) * 100);
|
||||
ctx.state.currentYPercent = clampYPercent(ctx, (marginBottom / getViewportHeight()) * 100);
|
||||
return ctx.state.currentYPercent;
|
||||
}
|
||||
|
||||
function applyYPercent(yPercent: number): void {
|
||||
const clampedPercent = clampYPercent(yPercent);
|
||||
const clampedPercent = clampYPercent(ctx, yPercent);
|
||||
ctx.state.currentYPercent = clampedPercent;
|
||||
const marginBottom = (clampedPercent / 100) * window.innerHeight;
|
||||
const marginBottom = (clampedPercent / 100) * getViewportHeight();
|
||||
|
||||
ctx.dom.subtitleContainer.style.position = '';
|
||||
ctx.dom.subtitleContainer.style.left = '';
|
||||
@@ -85,7 +125,7 @@ export function createInMemorySubtitlePositionController(
|
||||
}
|
||||
|
||||
const defaultMarginBottom = 60;
|
||||
const defaultYPercent = (defaultMarginBottom / window.innerHeight) * 100;
|
||||
const defaultYPercent = (defaultMarginBottom / getViewportHeight()) * 100;
|
||||
applyYPercent(defaultYPercent);
|
||||
console.log('Applied default subtitle position from', source);
|
||||
}
|
||||
|
||||
@@ -24,13 +24,16 @@ function withTempDir(fn: (dir: string) => void): void {
|
||||
}
|
||||
|
||||
test('getDefaultConfigDir prefers existing SubMiner config directory', () => {
|
||||
const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
|
||||
const homeDir = path.join(path.sep, 'tmp', 'home');
|
||||
const dir = getDefaultConfigDir({
|
||||
xdgConfigHome: '/tmp/xdg',
|
||||
homeDir: '/tmp/home',
|
||||
existsSync: (candidate) => candidate === '/tmp/xdg/SubMiner/config.jsonc',
|
||||
platform: 'linux',
|
||||
xdgConfigHome,
|
||||
homeDir,
|
||||
existsSync: (candidate) => candidate === path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||
});
|
||||
|
||||
assert.equal(dir, '/tmp/xdg/SubMiner');
|
||||
assert.equal(dir, path.join(xdgConfigHome, 'SubMiner'));
|
||||
});
|
||||
|
||||
test('ensureDefaultConfigBootstrap creates config dir and default jsonc only when missing', () => {
|
||||
@@ -61,6 +64,26 @@ test('ensureDefaultConfigBootstrap creates config dir and default jsonc only whe
|
||||
});
|
||||
});
|
||||
|
||||
test('ensureDefaultConfigBootstrap does not seed default config into an existing config directory', () => {
|
||||
withTempDir((root) => {
|
||||
const configDir = path.join(root, 'SubMiner');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'existing-user-file.txt'), 'keep\n');
|
||||
|
||||
ensureDefaultConfigBootstrap({
|
||||
configDir,
|
||||
configFilePaths: getDefaultConfigFilePaths(configDir),
|
||||
generateTemplate: () => 'should-not-write',
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(path.join(configDir, 'config.jsonc')), false);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(configDir, 'existing-user-file.txt'), 'utf8'),
|
||||
'keep\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('readSetupState ignores invalid files and round-trips valid state', () => {
|
||||
withTempDir((root) => {
|
||||
const statePath = getSetupStatePath(root);
|
||||
@@ -77,22 +100,113 @@ test('readSetupState ignores invalid files and round-trips valid state', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveDefaultMpvInstallPaths resolves linux and macOS defaults', () => {
|
||||
assert.deepEqual(resolveDefaultMpvInstallPaths('linux', '/tmp/home', '/tmp/xdg'), {
|
||||
supported: true,
|
||||
mpvConfigDir: '/tmp/xdg/mpv',
|
||||
scriptsDir: '/tmp/xdg/mpv/scripts',
|
||||
scriptOptsDir: '/tmp/xdg/mpv/script-opts',
|
||||
pluginDir: '/tmp/xdg/mpv/scripts/subminer',
|
||||
pluginConfigPath: '/tmp/xdg/mpv/script-opts/subminer.conf',
|
||||
});
|
||||
test('readSetupState migrates v1 state to v2 windows shortcut defaults', () => {
|
||||
withTempDir((root) => {
|
||||
const statePath = getSetupStatePath(root);
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
status: 'incomplete',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', '/Users/tester', undefined), {
|
||||
supported: true,
|
||||
mpvConfigDir: '/Users/tester/Library/Application Support/mpv',
|
||||
scriptsDir: '/Users/tester/Library/Application Support/mpv/scripts',
|
||||
scriptOptsDir: '/Users/tester/Library/Application Support/mpv/script-opts',
|
||||
pluginDir: '/Users/tester/Library/Application Support/mpv/scripts/subminer',
|
||||
pluginConfigPath: '/Users/tester/Library/Application Support/mpv/script-opts/subminer.conf',
|
||||
assert.deepEqual(readSetupState(statePath), {
|
||||
version: 2,
|
||||
status: 'incomplete',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults', () => {
|
||||
const linuxHomeDir = path.join(path.sep, 'tmp', 'home');
|
||||
const xdgConfigHome = path.join(path.sep, 'tmp', 'xdg');
|
||||
assert.deepEqual(resolveDefaultMpvInstallPaths('linux', linuxHomeDir, xdgConfigHome), {
|
||||
supported: true,
|
||||
mpvConfigDir: path.join(xdgConfigHome, 'mpv'),
|
||||
scriptsDir: path.join(xdgConfigHome, 'mpv', 'scripts'),
|
||||
scriptOptsDir: path.join(xdgConfigHome, 'mpv', 'script-opts'),
|
||||
pluginEntrypointPath: path.join(xdgConfigHome, 'mpv', 'scripts', 'subminer', 'main.lua'),
|
||||
pluginDir: path.join(xdgConfigHome, 'mpv', 'scripts', 'subminer'),
|
||||
pluginConfigPath: path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
});
|
||||
|
||||
const macHomeDir = path.join(path.sep, 'Users', 'tester');
|
||||
assert.deepEqual(resolveDefaultMpvInstallPaths('darwin', macHomeDir, undefined), {
|
||||
supported: true,
|
||||
mpvConfigDir: path.join(macHomeDir, 'Library', 'Application Support', 'mpv'),
|
||||
scriptsDir: path.join(macHomeDir, 'Library', 'Application Support', 'mpv', 'scripts'),
|
||||
scriptOptsDir: path.join(
|
||||
macHomeDir,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'mpv',
|
||||
'script-opts',
|
||||
),
|
||||
pluginEntrypointPath: path.join(
|
||||
macHomeDir,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'mpv',
|
||||
'scripts',
|
||||
'subminer',
|
||||
'main.lua',
|
||||
),
|
||||
pluginDir: path.join(
|
||||
macHomeDir,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'mpv',
|
||||
'scripts',
|
||||
'subminer',
|
||||
),
|
||||
pluginConfigPath: path.join(
|
||||
macHomeDir,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'mpv',
|
||||
'script-opts',
|
||||
'subminer.conf',
|
||||
),
|
||||
});
|
||||
|
||||
assert.deepEqual(resolveDefaultMpvInstallPaths('win32', 'C:\\Users\\tester', undefined), {
|
||||
supported: true,
|
||||
mpvConfigDir: path.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv'),
|
||||
scriptsDir: path.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv', 'scripts'),
|
||||
scriptOptsDir: path.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv', 'script-opts'),
|
||||
pluginEntrypointPath: path.join(
|
||||
'C:\\Users\\tester',
|
||||
'AppData',
|
||||
'Roaming',
|
||||
'mpv',
|
||||
'scripts',
|
||||
'subminer',
|
||||
'main.lua',
|
||||
),
|
||||
pluginDir: path.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv', 'scripts', 'subminer'),
|
||||
pluginConfigPath: path.join(
|
||||
'C:\\Users\\tester',
|
||||
'AppData',
|
||||
'Roaming',
|
||||
'mpv',
|
||||
'script-opts',
|
||||
'subminer.conf',
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,15 +6,23 @@ import { resolveConfigDir } from '../config/path-resolution';
|
||||
export type SetupStateStatus = 'incomplete' | 'in_progress' | 'completed' | 'cancelled';
|
||||
export type SetupCompletionSource = 'user' | 'legacy_auto_detected' | null;
|
||||
export type SetupPluginInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
||||
export type SetupWindowsMpvShortcutInstallStatus = 'unknown' | 'installed' | 'skipped' | 'failed';
|
||||
|
||||
export interface SetupWindowsMpvShortcutPreferences {
|
||||
startMenuEnabled: boolean;
|
||||
desktopEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface SetupState {
|
||||
version: 1;
|
||||
version: 2;
|
||||
status: SetupStateStatus;
|
||||
completedAt: string | null;
|
||||
completionSource: SetupCompletionSource;
|
||||
lastSeenYomitanDictionaryCount: number;
|
||||
pluginInstallStatus: SetupPluginInstallStatus;
|
||||
pluginInstallPathSummary: string | null;
|
||||
windowsMpvShortcutPreferences: SetupWindowsMpvShortcutPreferences;
|
||||
windowsMpvShortcutLastStatus: SetupWindowsMpvShortcutInstallStatus;
|
||||
}
|
||||
|
||||
export interface ConfigFilePaths {
|
||||
@@ -27,6 +35,7 @@ export interface MpvInstallPaths {
|
||||
mpvConfigDir: string;
|
||||
scriptsDir: string;
|
||||
scriptOptsDir: string;
|
||||
pluginEntrypointPath: string;
|
||||
pluginDir: string;
|
||||
pluginConfigPath: string;
|
||||
}
|
||||
@@ -39,25 +48,33 @@ function asObject(value: unknown): Record<string, unknown> | null {
|
||||
|
||||
export function createDefaultSetupState(): SetupState {
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'incomplete',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
const record = asObject(value);
|
||||
if (!record) return null;
|
||||
const version = record.version;
|
||||
const status = record.status;
|
||||
const pluginInstallStatus = record.pluginInstallStatus;
|
||||
const completionSource = record.completionSource;
|
||||
const windowsPrefs = asObject(record.windowsMpvShortcutPreferences);
|
||||
const windowsMpvShortcutLastStatus = record.windowsMpvShortcutLastStatus;
|
||||
|
||||
if (
|
||||
record.version !== 1 ||
|
||||
(version !== 1 && version !== 2) ||
|
||||
(status !== 'incomplete' &&
|
||||
status !== 'in_progress' &&
|
||||
status !== 'completed' &&
|
||||
@@ -66,6 +83,11 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
pluginInstallStatus !== 'installed' &&
|
||||
pluginInstallStatus !== 'skipped' &&
|
||||
pluginInstallStatus !== 'failed') ||
|
||||
(version === 2 &&
|
||||
windowsMpvShortcutLastStatus !== 'unknown' &&
|
||||
windowsMpvShortcutLastStatus !== 'installed' &&
|
||||
windowsMpvShortcutLastStatus !== 'skipped' &&
|
||||
windowsMpvShortcutLastStatus !== 'failed') ||
|
||||
(completionSource !== null &&
|
||||
completionSource !== 'user' &&
|
||||
completionSource !== 'legacy_auto_detected')
|
||||
@@ -74,7 +96,7 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
status,
|
||||
completedAt: typeof record.completedAt === 'string' ? record.completedAt : null,
|
||||
completionSource,
|
||||
@@ -87,6 +109,24 @@ export function normalizeSetupState(value: unknown): SetupState | null {
|
||||
pluginInstallStatus,
|
||||
pluginInstallPathSummary:
|
||||
typeof record.pluginInstallPathSummary === 'string' ? record.pluginInstallPathSummary : null,
|
||||
windowsMpvShortcutPreferences: {
|
||||
startMenuEnabled:
|
||||
version === 2 && typeof windowsPrefs?.startMenuEnabled === 'boolean'
|
||||
? windowsPrefs.startMenuEnabled
|
||||
: true,
|
||||
desktopEnabled:
|
||||
version === 2 && typeof windowsPrefs?.desktopEnabled === 'boolean'
|
||||
? windowsPrefs.desktopEnabled
|
||||
: true,
|
||||
},
|
||||
windowsMpvShortcutLastStatus:
|
||||
version === 2 &&
|
||||
(windowsMpvShortcutLastStatus === 'unknown' ||
|
||||
windowsMpvShortcutLastStatus === 'installed' ||
|
||||
windowsMpvShortcutLastStatus === 'skipped' ||
|
||||
windowsMpvShortcutLastStatus === 'failed')
|
||||
? windowsMpvShortcutLastStatus
|
||||
: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,11 +135,15 @@ export function isSetupCompleted(state: SetupState | null | undefined): boolean
|
||||
}
|
||||
|
||||
export function getDefaultConfigDir(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
appDataDir?: string;
|
||||
xdgConfigHome?: string;
|
||||
homeDir?: string;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
}): string {
|
||||
return resolveConfigDir({
|
||||
platform: options?.platform ?? process.platform,
|
||||
appDataDir: options?.appDataDir ?? process.env.APPDATA,
|
||||
xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
|
||||
homeDir: options?.homeDir ?? os.homedir(),
|
||||
existsSync: options?.existsSync ?? fs.existsSync,
|
||||
@@ -160,15 +204,17 @@ export function ensureDefaultConfigBootstrap(options: {
|
||||
const existsSync = options.existsSync ?? fs.existsSync;
|
||||
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
||||
const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
|
||||
const configDirExists = existsSync(options.configDir);
|
||||
|
||||
mkdirSync(options.configDir, { recursive: true });
|
||||
if (
|
||||
existsSync(options.configFilePaths.jsoncPath) ||
|
||||
existsSync(options.configFilePaths.jsonPath)
|
||||
existsSync(options.configFilePaths.jsonPath) ||
|
||||
configDirExists
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(options.configDir, { recursive: true });
|
||||
writeFileSync(options.configFilePaths.jsoncPath, options.generateTemplate(), 'utf8');
|
||||
}
|
||||
|
||||
@@ -185,10 +231,11 @@ export function resolveDefaultMpvInstallPaths(
|
||||
: path.join(homeDir, 'AppData', 'Roaming', 'mpv');
|
||||
|
||||
return {
|
||||
supported: platform === 'linux' || platform === 'darwin',
|
||||
supported: platform === 'linux' || platform === 'darwin' || platform === 'win32',
|
||||
mpvConfigDir,
|
||||
scriptsDir: path.join(mpvConfigDir, 'scripts'),
|
||||
scriptOptsDir: path.join(mpvConfigDir, 'script-opts'),
|
||||
pluginEntrypointPath: path.join(mpvConfigDir, 'scripts', 'subminer', 'main.lua'),
|
||||
pluginDir: path.join(mpvConfigDir, 'scripts', 'subminer'),
|
||||
pluginConfigPath: path.join(mpvConfigDir, 'script-opts', 'subminer.conf'),
|
||||
};
|
||||
|
||||
@@ -21,17 +21,15 @@ import { WindowGeometry } from '../types';
|
||||
export type GeometryChangeCallback = (geometry: WindowGeometry) => void;
|
||||
export type WindowFoundCallback = (geometry: WindowGeometry) => void;
|
||||
export type WindowLostCallback = () => void;
|
||||
export type WindowFocusChangeCallback = (focused: boolean) => void;
|
||||
|
||||
export abstract class BaseWindowTracker {
|
||||
protected currentGeometry: WindowGeometry | null = null;
|
||||
protected windowFound: boolean = false;
|
||||
protected focusKnown: boolean = false;
|
||||
protected windowFocused: boolean = false;
|
||||
protected targetWindowFocused: boolean = false;
|
||||
public onGeometryChange: GeometryChangeCallback | null = null;
|
||||
public onWindowFound: WindowFoundCallback | null = null;
|
||||
public onWindowLost: WindowLostCallback | null = null;
|
||||
public onWindowFocusChange: WindowFocusChangeCallback | null = null;
|
||||
public onTargetWindowFocusChange: ((focused: boolean) => void) | null = null;
|
||||
|
||||
abstract start(): void;
|
||||
abstract stop(): void;
|
||||
@@ -44,23 +42,24 @@ export abstract class BaseWindowTracker {
|
||||
return this.windowFound;
|
||||
}
|
||||
|
||||
isFocused(): boolean {
|
||||
return this.focusKnown ? this.windowFocused : this.windowFound;
|
||||
isTargetWindowFocused(): boolean {
|
||||
return this.targetWindowFocused;
|
||||
}
|
||||
|
||||
protected updateFocus(focused: boolean): void {
|
||||
const changed = !this.focusKnown || this.windowFocused !== focused;
|
||||
this.focusKnown = true;
|
||||
this.windowFocused = focused;
|
||||
if (changed) {
|
||||
this.onWindowFocusChange?.(focused);
|
||||
protected updateTargetWindowFocused(focused: boolean): void {
|
||||
if (this.targetWindowFocused === focused) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.targetWindowFocused = focused;
|
||||
this.onTargetWindowFocusChange?.(focused);
|
||||
}
|
||||
|
||||
protected updateGeometry(newGeometry: WindowGeometry | null): void {
|
||||
if (newGeometry) {
|
||||
if (!this.windowFound) {
|
||||
this.windowFound = true;
|
||||
this.updateTargetWindowFocused(true);
|
||||
if (this.onWindowFound) this.onWindowFound(newGeometry);
|
||||
}
|
||||
|
||||
@@ -75,14 +74,9 @@ export abstract class BaseWindowTracker {
|
||||
if (this.onGeometryChange) this.onGeometryChange(newGeometry);
|
||||
}
|
||||
} else {
|
||||
const focusChanged = this.focusKnown && this.windowFocused;
|
||||
this.focusKnown = false;
|
||||
this.windowFocused = false;
|
||||
if (focusChanged) {
|
||||
this.onWindowFocusChange?.(false);
|
||||
}
|
||||
if (this.windowFound) {
|
||||
this.windowFound = false;
|
||||
this.updateTargetWindowFocused(false);
|
||||
this.currentGeometry = null;
|
||||
if (this.onWindowLost) this.onWindowLost();
|
||||
}
|
||||
|
||||
@@ -21,14 +21,16 @@ import { HyprlandWindowTracker } from './hyprland-tracker';
|
||||
import { SwayWindowTracker } from './sway-tracker';
|
||||
import { X11WindowTracker } from './x11-tracker';
|
||||
import { MacOSWindowTracker } from './macos-tracker';
|
||||
import { WindowsWindowTracker } from './windows-tracker';
|
||||
import { createLogger } from '../logger';
|
||||
|
||||
const log = createLogger('tracker');
|
||||
|
||||
export type Compositor = 'hyprland' | 'sway' | 'x11' | 'macos' | null;
|
||||
export type Compositor = 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows' | null;
|
||||
export type Backend = 'auto' | Exclude<Compositor, null>;
|
||||
|
||||
export function detectCompositor(): Compositor {
|
||||
if (process.platform === 'win32') return 'windows';
|
||||
if (process.platform === 'darwin') return 'macos';
|
||||
if (process.env.HYPRLAND_INSTANCE_SIGNATURE) return 'hyprland';
|
||||
if (process.env.SWAYSOCK) return 'sway';
|
||||
@@ -42,6 +44,7 @@ function normalizeCompositor(value: string): Compositor | null {
|
||||
if (normalized === 'sway') return 'sway';
|
||||
if (normalized === 'x11') return 'x11';
|
||||
if (normalized === 'macos') return 'macos';
|
||||
if (normalized === 'windows') return 'windows';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -70,6 +73,8 @@ export function createWindowTracker(
|
||||
return new X11WindowTracker(targetMpvSocketPath?.trim() || undefined);
|
||||
case 'macos':
|
||||
return new MacOSWindowTracker(targetMpvSocketPath?.trim() || undefined);
|
||||
case 'windows':
|
||||
return new WindowsWindowTracker(targetMpvSocketPath?.trim() || undefined);
|
||||
default:
|
||||
log.warn('No supported compositor detected. Window tracking disabled.');
|
||||
return null;
|
||||
@@ -82,4 +87,5 @@ export {
|
||||
SwayWindowTracker,
|
||||
X11WindowTracker,
|
||||
MacOSWindowTracker,
|
||||
WindowsWindowTracker,
|
||||
};
|
||||
|
||||
111
src/window-trackers/windows-helper.test.ts
Normal file
111
src/window-trackers/windows-helper.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
parseWindowTrackerHelperFocusState,
|
||||
parseWindowTrackerHelperOutput,
|
||||
resolveWindowsTrackerHelper,
|
||||
} from './windows-helper';
|
||||
|
||||
test('parseWindowTrackerHelperOutput parses helper geometry output', () => {
|
||||
assert.deepEqual(parseWindowTrackerHelperOutput('120,240,1280,720'), {
|
||||
x: 120,
|
||||
y: 240,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseWindowTrackerHelperOutput returns null for misses and invalid payloads', () => {
|
||||
assert.equal(parseWindowTrackerHelperOutput('not-found'), null);
|
||||
assert.equal(parseWindowTrackerHelperOutput('1,2,3'), null);
|
||||
assert.equal(parseWindowTrackerHelperOutput('1,2,0,4'), null);
|
||||
});
|
||||
|
||||
test('parseWindowTrackerHelperFocusState parses helper stderr metadata', () => {
|
||||
assert.equal(parseWindowTrackerHelperFocusState('focus=focused'), true);
|
||||
assert.equal(parseWindowTrackerHelperFocusState('focus=not-focused'), false);
|
||||
assert.equal(parseWindowTrackerHelperFocusState('warning\nfocus=focused\nnote'), true);
|
||||
assert.equal(parseWindowTrackerHelperFocusState(''), null);
|
||||
});
|
||||
|
||||
test('resolveWindowsTrackerHelper auto mode prefers native helper when present', () => {
|
||||
const helper = resolveWindowsTrackerHelper({
|
||||
dirname: 'C:\\repo\\dist\\window-trackers',
|
||||
resourcesPath: 'C:\\repo\\resources',
|
||||
existsSync: (candidate) =>
|
||||
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
|
||||
helperModeEnv: 'auto',
|
||||
});
|
||||
|
||||
assert.deepEqual(helper, {
|
||||
kind: 'native',
|
||||
command: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
|
||||
args: [],
|
||||
helperPath: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveWindowsTrackerHelper auto mode falls back to powershell helper', () => {
|
||||
const helper = resolveWindowsTrackerHelper({
|
||||
dirname: 'C:\\repo\\dist\\window-trackers',
|
||||
resourcesPath: 'C:\\repo\\resources',
|
||||
existsSync: (candidate) =>
|
||||
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
|
||||
helperModeEnv: 'auto',
|
||||
});
|
||||
|
||||
assert.deepEqual(helper, {
|
||||
kind: 'powershell',
|
||||
command: 'powershell.exe',
|
||||
args: [
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
|
||||
],
|
||||
helperPath: 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveWindowsTrackerHelper explicit powershell mode ignores native helper', () => {
|
||||
const helper = resolveWindowsTrackerHelper({
|
||||
dirname: 'C:\\repo\\dist\\window-trackers',
|
||||
resourcesPath: 'C:\\repo\\resources',
|
||||
existsSync: (candidate) =>
|
||||
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.exe' ||
|
||||
candidate === 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1',
|
||||
helperModeEnv: 'powershell',
|
||||
});
|
||||
|
||||
assert.equal(helper?.kind, 'powershell');
|
||||
assert.equal(helper?.helperPath, 'C:\\repo\\resources\\scripts\\get-mpv-window-windows.ps1');
|
||||
});
|
||||
|
||||
test('resolveWindowsTrackerHelper explicit native mode fails cleanly when helper is missing', () => {
|
||||
const helper = resolveWindowsTrackerHelper({
|
||||
dirname: 'C:\\repo\\dist\\window-trackers',
|
||||
resourcesPath: 'C:\\repo\\resources',
|
||||
existsSync: () => false,
|
||||
helperModeEnv: 'native',
|
||||
});
|
||||
|
||||
assert.equal(helper, null);
|
||||
});
|
||||
|
||||
test('resolveWindowsTrackerHelper explicit helper path overrides default search', () => {
|
||||
const helper = resolveWindowsTrackerHelper({
|
||||
dirname: 'C:\\repo\\dist\\window-trackers',
|
||||
resourcesPath: 'C:\\repo\\resources',
|
||||
existsSync: (candidate) => candidate === 'D:\\custom\\tracker.ps1',
|
||||
helperModeEnv: 'auto',
|
||||
helperPathEnv: 'D:\\custom\\tracker.ps1',
|
||||
});
|
||||
|
||||
assert.deepEqual(helper, {
|
||||
kind: 'powershell',
|
||||
command: 'powershell.exe',
|
||||
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', 'D:\\custom\\tracker.ps1'],
|
||||
helperPath: 'D:\\custom\\tracker.ps1',
|
||||
});
|
||||
});
|
||||
282
src/window-trackers/windows-helper.ts
Normal file
282
src/window-trackers/windows-helper.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
SubMiner - All-in-one sentence mining overlay
|
||||
Copyright (C) 2024 sudacode
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import { createLogger } from '../logger';
|
||||
|
||||
const log = createLogger('tracker').child('windows-helper');
|
||||
|
||||
export type WindowsTrackerHelperKind = 'powershell' | 'native';
|
||||
export type WindowsTrackerHelperMode = 'auto' | 'powershell' | 'native';
|
||||
|
||||
export type WindowsTrackerHelperLaunchSpec = {
|
||||
kind: WindowsTrackerHelperKind;
|
||||
command: string;
|
||||
args: string[];
|
||||
helperPath: string;
|
||||
};
|
||||
|
||||
type ResolveWindowsTrackerHelperOptions = {
|
||||
dirname?: string;
|
||||
resourcesPath?: string;
|
||||
helperModeEnv?: string | undefined;
|
||||
helperPathEnv?: string | undefined;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||
copyFileSync?: (source: string, destination: string) => void;
|
||||
};
|
||||
|
||||
function normalizeHelperMode(value: string | undefined): WindowsTrackerHelperMode {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === 'powershell' || normalized === 'native') {
|
||||
return normalized;
|
||||
}
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
function inferHelperKindFromPath(helperPath: string): WindowsTrackerHelperKind | null {
|
||||
const normalized = helperPath.trim().toLowerCase();
|
||||
if (normalized.endsWith('.exe')) return 'native';
|
||||
if (normalized.endsWith('.ps1')) return 'powershell';
|
||||
return null;
|
||||
}
|
||||
|
||||
function materializeAsarHelper(
|
||||
sourcePath: string,
|
||||
kind: WindowsTrackerHelperKind,
|
||||
deps: Required<
|
||||
Pick<ResolveWindowsTrackerHelperOptions, 'mkdirSync' | 'copyFileSync'>
|
||||
>,
|
||||
): string | null {
|
||||
if (!sourcePath.includes('.asar')) {
|
||||
return sourcePath;
|
||||
}
|
||||
|
||||
const fileName =
|
||||
kind === 'native' ? 'get-mpv-window-windows.exe' : 'get-mpv-window-windows.ps1';
|
||||
const targetDir = path.join(os.tmpdir(), 'subminer', 'helpers');
|
||||
const targetPath = path.join(targetDir, fileName);
|
||||
|
||||
try {
|
||||
deps.mkdirSync(targetDir, { recursive: true });
|
||||
deps.copyFileSync(sourcePath, targetPath);
|
||||
log.info(`Materialized Windows helper from asar: ${targetPath}`);
|
||||
return targetPath;
|
||||
} catch (error) {
|
||||
log.warn(`Failed to materialize Windows helper from asar: ${sourcePath}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createLaunchSpec(
|
||||
helperPath: string,
|
||||
kind: WindowsTrackerHelperKind,
|
||||
): WindowsTrackerHelperLaunchSpec {
|
||||
if (kind === 'native') {
|
||||
return {
|
||||
kind,
|
||||
command: helperPath,
|
||||
args: [],
|
||||
helperPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind,
|
||||
command: 'powershell.exe',
|
||||
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', helperPath],
|
||||
helperPath,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHelperPathOverride(
|
||||
helperPathEnv: string | undefined,
|
||||
mode: WindowsTrackerHelperMode,
|
||||
): { path: string; kind: WindowsTrackerHelperKind } | null {
|
||||
const helperPath = helperPathEnv?.trim();
|
||||
if (!helperPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inferredKind = inferHelperKindFromPath(helperPath);
|
||||
const kind = mode === 'auto' ? inferredKind : mode;
|
||||
if (!kind) {
|
||||
log.warn(
|
||||
`Ignoring SUBMINER_WINDOWS_TRACKER_HELPER_PATH with unsupported extension: ${helperPath}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: helperPath, kind };
|
||||
}
|
||||
|
||||
function getHelperCandidates(dirname: string, resourcesPath: string | undefined): Array<{
|
||||
path: string;
|
||||
kind: WindowsTrackerHelperKind;
|
||||
}> {
|
||||
const scriptFileBase = 'get-mpv-window-windows';
|
||||
const candidates: Array<{ path: string; kind: WindowsTrackerHelperKind }> = [];
|
||||
|
||||
if (resourcesPath) {
|
||||
candidates.push({
|
||||
path: path.join(resourcesPath, 'scripts', `${scriptFileBase}.exe`),
|
||||
kind: 'native',
|
||||
});
|
||||
candidates.push({
|
||||
path: path.join(resourcesPath, 'scripts', `${scriptFileBase}.ps1`),
|
||||
kind: 'powershell',
|
||||
});
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
path: path.join(dirname, '..', 'scripts', `${scriptFileBase}.exe`),
|
||||
kind: 'native',
|
||||
});
|
||||
candidates.push({
|
||||
path: path.join(dirname, '..', 'scripts', `${scriptFileBase}.ps1`),
|
||||
kind: 'powershell',
|
||||
});
|
||||
candidates.push({
|
||||
path: path.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.exe`),
|
||||
kind: 'native',
|
||||
});
|
||||
candidates.push({
|
||||
path: path.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.ps1`),
|
||||
kind: 'powershell',
|
||||
});
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function parseWindowTrackerHelperOutput(output: string): WindowGeometry | null {
|
||||
const result = output.trim();
|
||||
if (!result || result === 'not-found') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = result.split(',');
|
||||
if (parts.length !== 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [xText, yText, widthText, heightText] = parts;
|
||||
const x = Number.parseInt(xText!, 10);
|
||||
const y = Number.parseInt(yText!, 10);
|
||||
const width = Number.parseInt(widthText!, 10);
|
||||
const height = Number.parseInt(heightText!, 10);
|
||||
if (
|
||||
!Number.isFinite(x) ||
|
||||
!Number.isFinite(y) ||
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
export function parseWindowTrackerHelperFocusState(output: string): boolean | null {
|
||||
const focusLine = output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('focus='));
|
||||
|
||||
if (!focusLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = focusLine.slice('focus='.length).trim().toLowerCase();
|
||||
if (value === 'focused') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'not-focused') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveWindowsTrackerHelper(
|
||||
options: ResolveWindowsTrackerHelperOptions = {},
|
||||
): WindowsTrackerHelperLaunchSpec | null {
|
||||
const existsSync = options.existsSync ?? fs.existsSync;
|
||||
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
||||
const copyFileSync = options.copyFileSync ?? fs.copyFileSync;
|
||||
const dirname = options.dirname ?? __dirname;
|
||||
const resourcesPath = options.resourcesPath ?? process.resourcesPath;
|
||||
const mode = normalizeHelperMode(
|
||||
options.helperModeEnv ?? process.env.SUBMINER_WINDOWS_TRACKER_HELPER,
|
||||
);
|
||||
const override = normalizeHelperPathOverride(
|
||||
options.helperPathEnv ?? process.env.SUBMINER_WINDOWS_TRACKER_HELPER_PATH,
|
||||
mode,
|
||||
);
|
||||
|
||||
if (override) {
|
||||
if (!existsSync(override.path)) {
|
||||
log.warn(`Configured Windows tracker helper path does not exist: ${override.path}`);
|
||||
return null;
|
||||
}
|
||||
const helperPath = materializeAsarHelper(override.path, override.kind, {
|
||||
mkdirSync,
|
||||
copyFileSync,
|
||||
});
|
||||
return helperPath ? createLaunchSpec(helperPath, override.kind) : null;
|
||||
}
|
||||
|
||||
const candidates = getHelperCandidates(dirname, resourcesPath);
|
||||
const orderedCandidates =
|
||||
mode === 'powershell'
|
||||
? candidates.filter((candidate) => candidate.kind === 'powershell')
|
||||
: mode === 'native'
|
||||
? candidates.filter((candidate) => candidate.kind === 'native')
|
||||
: candidates;
|
||||
|
||||
for (const candidate of orderedCandidates) {
|
||||
if (!existsSync(candidate.path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const helperPath = materializeAsarHelper(candidate.path, candidate.kind, {
|
||||
mkdirSync,
|
||||
copyFileSync,
|
||||
});
|
||||
if (!helperPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info(`Using Windows helper (${candidate.kind}): ${helperPath}`);
|
||||
return createLaunchSpec(helperPath, candidate.kind);
|
||||
}
|
||||
|
||||
if (mode === 'native') {
|
||||
log.warn('Windows native tracker helper requested but no helper was found.');
|
||||
} else if (mode === 'powershell') {
|
||||
log.warn('Windows PowerShell tracker helper requested but no helper was found.');
|
||||
} else {
|
||||
log.warn('Windows tracker helper not found.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
119
src/window-trackers/windows-tracker.test.ts
Normal file
119
src/window-trackers/windows-tracker.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { WindowsWindowTracker } from './windows-tracker';
|
||||
|
||||
test('WindowsWindowTracker skips overlapping polls while helper is in flight', async () => {
|
||||
let helperCalls = 0;
|
||||
let release: (() => void) | undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const tracker = new WindowsWindowTracker(undefined, {
|
||||
resolveHelper: () => ({
|
||||
kind: 'powershell',
|
||||
command: 'powershell.exe',
|
||||
args: ['-File', 'helper.ps1'],
|
||||
helperPath: 'helper.ps1',
|
||||
}),
|
||||
runHelper: async () => {
|
||||
helperCalls += 1;
|
||||
await gate;
|
||||
return {
|
||||
stdout: '0,0,640,360',
|
||||
stderr: 'focus=focused',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
assert.equal(helperCalls, 1);
|
||||
|
||||
assert.ok(release);
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker updates geometry from helper output', async () => {
|
||||
const tracker = new WindowsWindowTracker(undefined, {
|
||||
resolveHelper: () => ({
|
||||
kind: 'powershell',
|
||||
command: 'powershell.exe',
|
||||
args: ['-File', 'helper.ps1'],
|
||||
helperPath: 'helper.ps1',
|
||||
}),
|
||||
runHelper: async () => ({
|
||||
stdout: '10,20,1280,720',
|
||||
stderr: 'focus=focused',
|
||||
}),
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(tracker.getGeometry(), {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
});
|
||||
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker clears geometry for helper misses', async () => {
|
||||
const tracker = new WindowsWindowTracker(undefined, {
|
||||
resolveHelper: () => ({
|
||||
kind: 'powershell',
|
||||
command: 'powershell.exe',
|
||||
args: ['-File', 'helper.ps1'],
|
||||
helperPath: 'helper.ps1',
|
||||
}),
|
||||
runHelper: async () => ({
|
||||
stdout: 'not-found',
|
||||
stderr: 'focus=not-focused',
|
||||
}),
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.getGeometry(), null);
|
||||
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||
});
|
||||
|
||||
test('WindowsWindowTracker retries without socket filter when filtered helper lookup misses', async () => {
|
||||
const helperCalls: Array<string | null> = [];
|
||||
const tracker = new WindowsWindowTracker('\\\\.\\pipe\\subminer-socket', {
|
||||
resolveHelper: () => ({
|
||||
kind: 'powershell',
|
||||
command: 'powershell.exe',
|
||||
args: ['-File', 'helper.ps1'],
|
||||
helperPath: 'helper.ps1',
|
||||
}),
|
||||
runHelper: async (_spec, _mode, targetMpvSocketPath) => {
|
||||
helperCalls.push(targetMpvSocketPath);
|
||||
if (targetMpvSocketPath) {
|
||||
return {
|
||||
stdout: 'not-found',
|
||||
stderr: 'focus=not-focused',
|
||||
};
|
||||
}
|
||||
return {
|
||||
stdout: '25,30,1440,810',
|
||||
stderr: 'focus=focused',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(helperCalls, ['\\\\.\\pipe\\subminer-socket', null]);
|
||||
assert.deepEqual(tracker.getGeometry(), {
|
||||
x: 25,
|
||||
y: 30,
|
||||
width: 1440,
|
||||
height: 810,
|
||||
});
|
||||
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||
});
|
||||
176
src/window-trackers/windows-tracker.ts
Normal file
176
src/window-trackers/windows-tracker.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
SubMiner - All-in-one sentence mining overlay
|
||||
Copyright (C) 2024 sudacode
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { execFile, type ExecFileException } from 'child_process';
|
||||
import { BaseWindowTracker } from './base-tracker';
|
||||
import {
|
||||
parseWindowTrackerHelperFocusState,
|
||||
parseWindowTrackerHelperOutput,
|
||||
resolveWindowsTrackerHelper,
|
||||
type WindowsTrackerHelperLaunchSpec,
|
||||
} from './windows-helper';
|
||||
import { createLogger } from '../logger';
|
||||
|
||||
const log = createLogger('tracker').child('windows');
|
||||
|
||||
type WindowsTrackerRunnerResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
type WindowsTrackerDeps = {
|
||||
resolveHelper?: () => WindowsTrackerHelperLaunchSpec | null;
|
||||
runHelper?: (
|
||||
spec: WindowsTrackerHelperLaunchSpec,
|
||||
mode: 'geometry',
|
||||
targetMpvSocketPath: string | null,
|
||||
) => Promise<WindowsTrackerRunnerResult>;
|
||||
};
|
||||
|
||||
function runHelperWithExecFile(
|
||||
spec: WindowsTrackerHelperLaunchSpec,
|
||||
mode: 'geometry',
|
||||
targetMpvSocketPath: string | null,
|
||||
): Promise<WindowsTrackerRunnerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const modeArgs =
|
||||
spec.kind === 'native' ? ['--mode', mode] : ['-Mode', mode];
|
||||
const args = targetMpvSocketPath
|
||||
? [...spec.args, ...modeArgs, targetMpvSocketPath]
|
||||
: [...spec.args, ...modeArgs];
|
||||
execFile(
|
||||
spec.command,
|
||||
args,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
windowsHide: true,
|
||||
},
|
||||
(error: ExecFileException | null, stdout: string, stderr: string) => {
|
||||
if (error) {
|
||||
reject(Object.assign(error, { stderr }));
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export class WindowsWindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private pollInFlight = false;
|
||||
private helperSpec: WindowsTrackerHelperLaunchSpec | null;
|
||||
private readonly targetMpvSocketPath: string | null;
|
||||
private readonly runHelper: (
|
||||
spec: WindowsTrackerHelperLaunchSpec,
|
||||
mode: 'geometry',
|
||||
targetMpvSocketPath: string | null,
|
||||
) => Promise<WindowsTrackerRunnerResult>;
|
||||
private lastExecErrorFingerprint: string | null = null;
|
||||
private lastExecErrorLoggedAtMs = 0;
|
||||
|
||||
constructor(targetMpvSocketPath?: string, deps: WindowsTrackerDeps = {}) {
|
||||
super();
|
||||
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
|
||||
this.helperSpec = deps.resolveHelper ? deps.resolveHelper() : resolveWindowsTrackerHelper();
|
||||
this.runHelper = deps.runHelper ?? runHelperWithExecFile;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
||||
this.pollGeometry();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private maybeLogExecError(error: Error, stderr: string): void {
|
||||
const now = Date.now();
|
||||
const fingerprint = `${error.message}|${stderr.trim()}`;
|
||||
const shouldLog =
|
||||
this.lastExecErrorFingerprint !== fingerprint || now - this.lastExecErrorLoggedAtMs >= 5000;
|
||||
if (!shouldLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastExecErrorFingerprint = fingerprint;
|
||||
this.lastExecErrorLoggedAtMs = now;
|
||||
log.warn('Windows helper execution failed', {
|
||||
helperPath: this.helperSpec?.helperPath ?? null,
|
||||
helperKind: this.helperSpec?.kind ?? null,
|
||||
error: error.message,
|
||||
stderr: stderr.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
private async runHelperWithSocketFallback(): Promise<WindowsTrackerRunnerResult> {
|
||||
if (!this.helperSpec) {
|
||||
return { stdout: 'not-found', stderr: '' };
|
||||
}
|
||||
|
||||
try {
|
||||
const primary = await this.runHelper(this.helperSpec, 'geometry', this.targetMpvSocketPath);
|
||||
const primaryGeometry = parseWindowTrackerHelperOutput(primary.stdout);
|
||||
if (primaryGeometry || !this.targetMpvSocketPath) {
|
||||
return primary;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.targetMpvSocketPath) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.runHelper(this.helperSpec, 'geometry', null);
|
||||
}
|
||||
|
||||
private pollGeometry(): void {
|
||||
if (this.pollInFlight || !this.helperSpec) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollInFlight = true;
|
||||
void this.runHelperWithSocketFallback()
|
||||
.then(({ stdout, stderr }) => {
|
||||
const geometry = parseWindowTrackerHelperOutput(stdout);
|
||||
const focusState = parseWindowTrackerHelperFocusState(stderr);
|
||||
this.updateTargetWindowFocused(focusState ?? Boolean(geometry));
|
||||
this.updateGeometry(geometry);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const stderr =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'stderr' in error &&
|
||||
typeof (error as { stderr?: unknown }).stderr === 'string'
|
||||
? (error as { stderr: string }).stderr
|
||||
: '';
|
||||
this.maybeLogExecError(err, stderr);
|
||||
this.updateGeometry(null);
|
||||
})
|
||||
.finally(() => {
|
||||
this.pollInFlight = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
2
vendor/subminer-yomitan
vendored
2
vendor/subminer-yomitan
vendored
Submodule vendor/subminer-yomitan updated: 66cb7a06f1...9863d865e1
2
vendor/texthooker-ui
vendored
2
vendor/texthooker-ui
vendored
Submodule vendor/texthooker-ui updated: e8c7ae1122...534cd66b6e
Reference in New Issue
Block a user