diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c158c53..a42f18e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,6 @@ jobs: 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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dcad15c..a13bd13 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ concurrency: cancel-in-progress: false permissions: + actions: read contents: write jobs: @@ -26,11 +27,6 @@ jobs: 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: @@ -85,11 +81,6 @@ jobs: 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: @@ -147,11 +138,6 @@ jobs: 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: @@ -211,8 +197,100 @@ 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: 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 +310,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: @@ -270,7 +354,7 @@ jobs: - name: Generate checksums run: | shopt -s nullglob - files=(release/*.AppImage release/*.dmg release/*.zip release/*.tar.gz dist/launcher/subminer) + files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz dist/launcher/subminer) if [ "${#files[@]}" -eq 0 ]; then echo "No release artifacts found for checksum generation." exit 1 @@ -308,6 +392,7 @@ jobs: artifacts=( release/*.AppImage release/*.dmg + release/*.exe release/*.zip release/*.tar.gz release/SHA256SUMS.txt diff --git a/Makefile b/Makefile index 8e46b27..8655e65 100644 --- a/Makefile +++ b/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)" @@ -105,6 +117,7 @@ build: @case "$(PLATFORM)" in \ linux) $(MAKE) --no-print-directory build-linux ;; \ macos) $(MAKE) --no-print-directory build-macos ;; \ + windows) printf '%s\n' "[INFO] Windows builds run via: bun run build:win" ;; \ *) printf '%s\n' "[ERROR] Unsupported OS for this Makefile target: $(PLATFORM)"; exit 1 ;; \ esac @@ -113,6 +126,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 +224,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 \ + bun ./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 +260,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" diff --git a/README.md b/README.md index 70d9625..4264c27 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) -[![Linux](https://img.shields.io/badge/platform-Linux%20%7C%20macOS-informational)]() +[![Linux](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-informational)]() [![Docs](https://img.shields.io/badge/docs-docs.subminer.moe-blueviolet)](https://docs.subminer.moe) @@ -20,17 +20,24 @@
+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-.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`). Bundled Yomitan is built from the `vendor/subminer-yomitan` submodule into `build/yomitan` during `bun run build`, so source builds only need Bun for the JS toolchain. Packaged macOS and Windows installs do not require Bun. Windows installer builds go through `electron-builder`; its bundled `app-builder-lib` NSIS templates already use the third-party `WinShell` plugin for shortcut AppUserModelID assignment, and the `WinShell.dll` binary is supplied by electron-builder's cached `nsis-resources` bundle, so `bun run build:win` does not need a separate repo-local plugin install step. 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` (source builds, Linux `subminer`) | | | `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 the maintained dist compatibility slice for `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and runtime registry coverage. +- Run `bun run test:node:compat` directly when you only need that dist compatibility slice. The command name is legacy; it now runs under Bun. +- 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 the dist SQLite persistence coverage. +- Run `bun run test:subtitle` for the maintained `alass`/`ffsubsync` subtitle surface. + +The Bun-managed discovery lanes intentionally exclude a small set of suites from the source-file discovery pass and keep them in the maintained dist compatibility slice instead: Electron-focused 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 diff --git a/backlog/tasks/task-117 - Prepare-initial-Windows-release-docs-and-version-bump.md b/backlog/tasks/task-117 - Prepare-initial-Windows-release-docs-and-version-bump.md new file mode 100644 index 0000000..b1a70b2 --- /dev/null +++ b/backlog/tasks/task-117 - Prepare-initial-Windows-release-docs-and-version-bump.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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 + + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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. + + +## Final Summary + + +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`. + diff --git a/backlog/tasks/task-118 - Add-Windows-release-build-and-SignPath-signing.md b/backlog/tasks/task-118 - Add-Windows-release-build-and-SignPath-signing.md new file mode 100644 index 0000000..04117a9 --- /dev/null +++ b/backlog/tasks/task-118 - Add-Windows-release-build-and-SignPath-signing.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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 + + +## Implementation Plan + + +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. + + +## Implementation Notes + + +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. + + +## Final Summary + + +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. + diff --git a/build/installer.nsh b/build/installer.nsh new file mode 100644 index 0000000..4ba2d41 --- /dev/null +++ b/build/installer.nsh @@ -0,0 +1,153 @@ +!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" + # electron-builder's upstream NSIS templates use the same WinShell call for AppUserModelID wiring. + # WinShell.dll comes from electron-builder's cached nsis-resources bundle, so bun run build:win needs no extra repo-local setup. + 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 keeps the optional AUMI assignment non-fatal if the packaging environment is missing WinShell. + 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 diff --git a/build/signpath-windows-artifact-config.xml b/build/signpath-windows-artifact-config.xml new file mode 100644 index 0000000..87de13b --- /dev/null +++ b/build/signpath-windows-artifact-config.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/changes/windows-background-reuse.md b/changes/windows-background-reuse.md new file mode 100644 index 0000000..2279d93 --- /dev/null +++ b/changes/windows-background-reuse.md @@ -0,0 +1,4 @@ +type: fixed +area: windows + +- Acquire the app single-instance lock earlier so Windows overlay/video launches reuse the running background SubMiner process instead of booting a second full app and repeating startup warmups. diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index 7c2da07..8f28b62 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -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'); +}); diff --git a/launcher/config-path.ts b/launcher/config-path.ts index 33786af..a900e28 100644 --- a/launcher/config-path.ts +++ b/launcher/config-path.ts @@ -4,6 +4,7 @@ import { resolveConfigFilePath } from '../src/config/path-resolution.js'; export function resolveMainConfigPath(): string { return resolveConfigFilePath({ + appDataDir: process.env.APPDATA, xdgConfigHome: process.env.XDG_CONFIG_HOME, homeDir: os.homedir(), existsSync: fs.existsSync, diff --git a/launcher/config/plugin-runtime-config.ts b/launcher/config/plugin-runtime-config.ts index dc7f208..64498a1 100644 --- a/launcher/config/plugin-runtime-config.ts +++ b/launcher/config/plugin-runtime-config.ts @@ -5,12 +5,36 @@ 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'); +function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 { + return platform === 'win32' ? path.win32 : path.posix; +} + +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(); + const platformPath = getPlatformPath(platform); + + if (platform === 'win32') { + const appDataDir = + options?.appDataDir?.trim() || + process.env.APPDATA?.trim() || + platformPath.join(homeDir, 'AppData', 'Roaming'); + return [platformPath.join(appDataDir, 'mpv', 'script-opts', 'subminer.conf')]; + } + + const xdgConfigHome = + options?.xdgConfigHome?.trim() || + process.env.XDG_CONFIG_HOME || + platformPath.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'), + platformPath.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), + platformPath.join(homeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'), ]), ); } diff --git a/launcher/config/shared-config-reader.ts b/launcher/config/shared-config-reader.ts index 3d9228b..adf5364 100644 --- a/launcher/config/shared-config-reader.ts +++ b/launcher/config/shared-config-reader.ts @@ -5,6 +5,7 @@ import { resolveConfigFilePath } from '../../src/config/path-resolution.js'; export function resolveLauncherMainConfigPath(): string { return resolveConfigFilePath({ + appDataDir: process.env.APPDATA, xdgConfigHome: process.env.XDG_CONFIG_HOME, homeDir: os.homedir(), existsSync: fs.existsSync, diff --git a/launcher/log.test.ts b/launcher/log.test.ts new file mode 100644 index 0000000..3e97dbe --- /dev/null +++ b/launcher/log.test.ts @@ -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`, + ), + ), + ); +}); diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 62af91e..236ba40 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -51,10 +51,16 @@ function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult { } function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv { + const pathValue = process.env.Path || process.env.PATH || ''; return { ...process.env, HOME: homeDir, + USERPROFILE: homeDir, + APPDATA: xdgConfigHome, + LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'), XDG_CONFIG_HOME: xdgConfigHome, + PATH: pathValue, + Path: pathValue, }; } @@ -75,13 +81,14 @@ test('config path uses XDG_CONFIG_HOME override', () => { test('config discovery ignores lowercase subminer candidate', () => { const homeDir = '/home/tester'; const xdgConfigHome = '/tmp/xdg-config'; - const expected = path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'); - const foundPaths = new Set([path.join(xdgConfigHome, 'subminer', 'config.json')]); + const expected = path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'); + const foundPaths = new Set([path.posix.join(xdgConfigHome, 'subminer', 'config.json')]); const resolved = resolveConfigFilePath({ xdgConfigHome, homeDir, - existsSync: (candidate) => foundPaths.has(path.normalize(candidate)), + platform: 'linux', + existsSync: (candidate) => foundPaths.has(path.posix.normalize(candidate)), }); assert.equal(resolved, expected); @@ -138,6 +145,12 @@ test('mpv status exits non-zero when socket is not ready', () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); const xdgConfigHome = path.join(root, 'xdg'); + const socketPath = path.join(root, 'missing.sock'); + fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true }); + fs.writeFileSync( + path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), + `socket_path=${socketPath}\n`, + ); const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome)); assert.equal(result.status, 1); @@ -152,6 +165,7 @@ test('doctor reports checks and exits non-zero without hard dependencies', () => const env = { ...makeTestEnv(homeDir, xdgConfigHome), PATH: '', + Path: '', }; const result = runLauncher(['doctor'], env); @@ -184,7 +198,7 @@ test('youtube command rejects removed --mode option', () => { }); }); -test('youtube playback generates subtitles before mpv launch', () => { +test('youtube playback generates subtitles before mpv launch', { timeout: 15000 }, () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); const xdgConfigHome = path.join(root, 'xdg'); @@ -194,6 +208,7 @@ test('youtube playback generates subtitles before mpv launch', () => { const mpvCapturePath = path.join(root, 'mpv-order.txt'); const mpvArgsPath = path.join(root, 'mpv-args.txt'); const socketPath = path.join(root, 'mpv.sock'); + const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/')); fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); @@ -264,7 +279,7 @@ for arg in "$@"; do ;; esac done -bun -e "const net=require('node:net'); const fs=require('node:fs'); const socket=process.argv[1]; try { fs.rmSync(socket,{force:true}); } catch {} const server=net.createServer((conn)=>conn.end()); server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250));" "$socket_path" +${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const socket=process.argv[1]; try { fs.rmSync(socket,{force:true}); } catch {} const server=net.createServer((conn)=>conn.end()); server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250));" "$socket_path" `, 'utf8', ); @@ -272,7 +287,8 @@ bun -e "const net=require('node:net'); const fs=require('node:fs'); const socket const env = { ...makeTestEnv(homeDir, xdgConfigHome), - PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`, + PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, + Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, SUBMINER_APPIMAGE_PATH: appPath, SUBMINER_TEST_YTDLP_LOG: ytdlpLogPath, SUBMINER_TEST_MPV_ORDER: mpvCapturePath, @@ -280,7 +296,7 @@ bun -e "const net=require('node:net'); const fs=require('node:fs'); const socket }; const result = runLauncher(['youtube', 'https://www.youtube.com/watch?v=test123'], env); - assert.equal(result.status, 0); + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); assert.equal(fs.readFileSync(mpvCapturePath, 'utf8').trim(), 'generated-before-mpv'); assert.match( fs.readFileSync(mpvArgsPath, 'utf8'), @@ -528,15 +544,20 @@ test('parseJellyfinPreviewAuthResponse returns null for invalid payloads', () => }); test('deriveJellyfinTokenStorePath resolves alongside config path', () => { - const tokenPath = deriveJellyfinTokenStorePath('/home/test/.config/SubMiner/config.jsonc'); - assert.equal(tokenPath, '/home/test/.config/SubMiner/jellyfin-token-store.json'); + const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc'); + const tokenPath = deriveJellyfinTokenStorePath(configPath); + assert.equal(tokenPath, path.join(path.dirname(configPath), 'jellyfin-token-store.json')); }); test('hasStoredJellyfinSession checks token-store existence', () => { - const exists = (candidate: string): boolean => - candidate === '/home/test/.config/SubMiner/jellyfin-token-store.json'; - assert.equal(hasStoredJellyfinSession('/home/test/.config/SubMiner/config.jsonc', exists), true); - assert.equal(hasStoredJellyfinSession('/home/test/.config/Other/alt.jsonc', exists), false); + const configPath = path.join('/home/test', '.config', 'SubMiner', 'config.jsonc'); + const tokenPath = deriveJellyfinTokenStorePath(configPath); + const exists = (candidate: string): boolean => candidate === tokenPath; + assert.equal(hasStoredJellyfinSession(configPath, exists), true); + assert.equal( + hasStoredJellyfinSession(path.join('/home/test', '.config', 'Other', 'alt.jsonc'), exists), + false, + ); }); test('shouldRetryWithStartForNoRunningInstance matches expected app lifecycle error', () => { diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 5c0651d..5beee3b 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -9,8 +9,10 @@ import { log, fail, getMpvLogPath } from './log.js'; import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js'; import { commandExists, + getPathEnv, isExecutable, resolveBinaryPathCandidate, + resolveCommandInvocation, realpathMaybe, isYoutubeTarget, uniqueNormalizedLangCodes, @@ -27,6 +29,11 @@ export const state = { stopRequested: false, }; +type SpawnTarget = { + command: string; + args: string[]; +}; + const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid'); const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900; const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700; @@ -199,7 +206,8 @@ export function findAppBinary(selfPath: string): string | null { if (isExecutable(candidate)) return candidate; } - const fromPath = process.env.PATH?.split(path.delimiter) + const fromPath = getPathEnv() + .split(path.delimiter) .map((dir) => path.join(dir, 'subminer')) .find((candidate) => isExecutable(candidate)); @@ -512,7 +520,8 @@ export async function startMpv( mpvArgs.push(`--input-ipc-server=${socketPath}`); mpvArgs.push(target); - state.mpvProc = spawn('mpv', mpvArgs, { stdio: 'inherit' }); + const mpvTarget = resolveCommandInvocation('mpv', mpvArgs); + state.mpvProc = spawn(mpvTarget.command, mpvTarget.args, { stdio: 'inherit' }); } async function waitForOverlayStartCommandSettled( @@ -563,7 +572,8 @@ export async function startOverlay(appPath: string, args: Args, socketPath: stri if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); if (args.useTexthooker) overlayArgs.push('--texthooker'); - state.overlayProc = spawn(appPath, overlayArgs, { + const target = resolveAppSpawnTarget(appPath, overlayArgs); + state.overlayProc = spawn(target.command, target.args, { stdio: 'inherit', env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() }, }); @@ -682,8 +692,30 @@ function buildAppEnv(): NodeJS.ProcessEnv { return env; } +function maybeCaptureAppArgs(appArgs: string[]): boolean { + const capturePath = process.env.SUBMINER_TEST_CAPTURE?.trim(); + if (!capturePath) { + return false; + } + + fs.writeFileSync(capturePath, `${appArgs.join('\n')}${appArgs.length > 0 ? '\n' : ''}`, 'utf8'); + return true; +} + +function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget { + if (process.platform !== 'win32') { + return { command: appPath, args: appArgs }; + } + return resolveCommandInvocation(appPath, appArgs); +} + export function runAppCommandWithInherit(appPath: string, appArgs: string[]): never { - const result = spawnSync(appPath, appArgs, { + if (maybeCaptureAppArgs(appArgs)) { + process.exit(0); + } + + const target = resolveAppSpawnTarget(appPath, appArgs); + const result = spawnSync(target.command, target.args, { stdio: 'inherit', env: buildAppEnv(), }); @@ -702,7 +734,16 @@ export function runAppCommandCaptureOutput( stderr: string; error?: Error; } { - const result = spawnSync(appPath, appArgs, { + if (maybeCaptureAppArgs(appArgs)) { + return { + status: 0, + stdout: '', + stderr: '', + }; + } + + const target = resolveAppSpawnTarget(appPath, appArgs); + const result = spawnSync(target.command, target.args, { env: buildAppEnv(), encoding: 'utf8', }); @@ -721,8 +762,17 @@ export function runAppCommandWithInheritLogged( logLevel: LogLevel, label: string, ): never { - log('debug', logLevel, `${label}: launching app with args: ${appArgs.join(' ')}`); - const result = spawnSync(appPath, appArgs, { + if (maybeCaptureAppArgs(appArgs)) { + process.exit(0); + } + + const target = resolveAppSpawnTarget(appPath, appArgs); + log( + 'debug', + logLevel, + `${label}: launching app with args: ${[target.command, ...target.args].join(' ')}`, + ); + const result = spawnSync(target.command, target.args, { stdio: 'inherit', env: buildAppEnv(), }); @@ -736,7 +786,11 @@ export function runAppCommandWithInheritLogged( export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void { const startArgs = ['--start']; if (logLevel !== 'info') startArgs.push('--log-level', logLevel); - const proc = spawn(appPath, startArgs, { + if (maybeCaptureAppArgs(startArgs)) { + return; + } + const target = resolveAppSpawnTarget(appPath, startArgs); + const proc = spawn(target.command, target.args, { stdio: 'ignore', detached: true, env: buildAppEnv(), @@ -766,7 +820,8 @@ export function launchMpvIdleDetached( ); mpvArgs.push(`--log-file=${getMpvLogPath()}`); mpvArgs.push(`--input-ipc-server=${socketPath}`); - const proc = spawn('mpv', mpvArgs, { + const mpvTarget = resolveCommandInvocation('mpv', mpvArgs); + const proc = spawn(mpvTarget.command, mpvTarget.args, { stdio: 'ignore', detached: true, }); diff --git a/launcher/setup-gate.test.ts b/launcher/setup-gate.test.ts index 2f32e5f..db3c8fb 100644 --- a/launcher/setup-gate.test.ts +++ b/launcher/setup-gate.test.ts @@ -7,22 +7,26 @@ test('waitForSetupCompletion resolves completed and cancelled states', async () const sequence: Array = [ 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, diff --git a/launcher/types.ts b/launcher/types.ts index 88e0fa9..fbb6a13 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -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 = [ diff --git a/launcher/util.ts b/launcher/util.ts index ed30272..649c2a8 100644 --- a/launcher/util.ts +++ b/launcher/util.ts @@ -18,14 +18,139 @@ export function isExecutable(filePath: string): boolean { } } -export function commandExists(command: string): boolean { - const pathEnv = process.env.PATH ?? ''; +function isRunnableFile(filePath: string): boolean { + try { + if (!fs.statSync(filePath).isFile()) return false; + return process.platform === 'win32' ? true : isExecutable(filePath); + } catch { + return false; + } +} + +function isPathLikeCommand(command: string): boolean { + return ( + command.includes('/') || + command.includes('\\') || + /^[A-Za-z]:[\\/]/.test(command) || + command.startsWith('.') + ); +} + +function getWindowsPathExts(): string[] { + const raw = process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD'; + return raw + .split(';') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +export function getPathEnv(): string { + const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === 'path'); + return pathKey ? (process.env[pathKey] ?? '') : ''; +} + +function resolveExecutablePath(command: string): string | null { + const tryCandidate = (candidate: string): string | null => + isRunnableFile(candidate) ? candidate : null; + + const resolveWindowsCandidate = (candidate: string): string | null => { + const direct = tryCandidate(candidate); + if (direct) return direct; + if (path.extname(candidate)) return null; + for (const ext of getWindowsPathExts()) { + const withExt = tryCandidate(`${candidate}${ext}`); + if (withExt) return withExt; + } + return null; + }; + + if (isPathLikeCommand(command)) { + const resolved = path.resolve(resolvePathMaybe(command)); + return process.platform === 'win32' ? resolveWindowsCandidate(resolved) : tryCandidate(resolved); + } + + const pathEnv = getPathEnv(); for (const dir of pathEnv.split(path.delimiter)) { if (!dir) continue; - const full = path.join(dir, command); - if (isExecutable(full)) return true; + const candidate = path.join(dir, command); + const resolved = + process.platform === 'win32' ? resolveWindowsCandidate(candidate) : tryCandidate(candidate); + if (resolved) return resolved; } - return false; + return null; +} + +function normalizeWindowsBashArg(value: string): string { + const normalized = value.replace(/\\/g, '/'); + const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/); + if (!driveMatch) { + return normalized; + } + + const [, driveLetter, remainder] = driveMatch; + return `/mnt/${driveLetter!.toLowerCase()}/${remainder}`; +} + +function resolveGitBashExecutable(): string | null { + const directCandidates = [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', + ]; + for (const candidate of directCandidates) { + if (isRunnableFile(candidate)) return candidate; + } + + const gitExecutable = resolveExecutablePath('git'); + if (!gitExecutable) return null; + const gitDir = path.dirname(gitExecutable); + const inferredCandidates = [ + path.resolve(gitDir, '..', 'bin', 'bash.exe'), + path.resolve(gitDir, '..', 'usr', 'bin', 'bash.exe'), + ]; + for (const candidate of inferredCandidates) { + if (isRunnableFile(candidate)) return candidate; + } + return null; +} + +function resolveWindowsBashTarget(): { + command: string; + flavor: 'git' | 'wsl'; +} { + const gitBash = resolveGitBashExecutable(); + if (gitBash) { + return { command: gitBash, flavor: 'git' }; + } + return { + command: resolveExecutablePath('bash') ?? 'bash', + flavor: 'wsl', + }; +} + +function normalizeWindowsShellArg(value: string, flavor: 'git' | 'wsl'): string { + if (!isPathLikeCommand(value)) { + return value; + } + return flavor === 'git' ? value.replace(/\\/g, '/') : normalizeWindowsBashArg(value); +} + +function readShebang(filePath: string): string { + try { + const fd = fs.openSync(filePath, 'r'); + try { + const buffer = Buffer.alloc(160); + const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0); + return buffer.toString('utf8', 0, bytesRead).split(/\r?\n/, 1)[0] ?? ''; + } finally { + fs.closeSync(fd); + } + } catch { + return ''; + } +} + +export function commandExists(command: string): boolean { + return resolveExecutablePath(command) !== null; } export function resolvePathMaybe(input: string): string { @@ -116,6 +241,51 @@ export function inferWhisperLanguage(langCodes: string[], fallback: string): str return fallback; } +export function resolveCommandInvocation( + executable: string, + args: string[], +): { command: string; args: string[] } { + if (process.platform !== 'win32') { + return { command: executable, args }; + } + + const resolvedExecutable = resolveExecutablePath(executable) ?? executable; + const extension = path.extname(resolvedExecutable).toLowerCase(); + if (extension === '.ps1') { + return { + command: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolvedExecutable, ...args], + }; + } + + if (extension === '.sh') { + const bashTarget = resolveWindowsBashTarget(); + return { + command: bashTarget.command, + args: [ + normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor), + ...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)), + ], + }; + } + + if (!extension) { + const shebang = readShebang(resolvedExecutable); + if (/^#!.*\b(?:sh|bash)\b/i.test(shebang)) { + const bashTarget = resolveWindowsBashTarget(); + return { + command: bashTarget.command, + args: [ + normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor), + ...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)), + ], + }; + } + } + + return { command: resolvedExecutable, args }; +} + export function runExternalCommand( executable: string, args: string[], @@ -129,8 +299,13 @@ export function runExternalCommand( const streamOutput = opts.streamOutput === true; return new Promise((resolve, reject) => { - log('debug', configuredLogLevel, `[${commandLabel}] spawn: ${executable} ${args.join(' ')}`); - const child = spawn(executable, args, { + const target = resolveCommandInvocation(executable, args); + log( + 'debug', + configuredLogLevel, + `[${commandLabel}] spawn: ${target.command} ${target.args.join(' ')}`, + ); + const child = spawn(target.command, target.args, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...opts.env }, }); @@ -201,7 +376,7 @@ export function runExternalCommand( `[${commandLabel}] exit code ${code ?? 1}`, ); if (code !== 0 && !allowFailure) { - const commandString = `${executable} ${args.join(' ')}`; + const commandString = `${target.command} ${target.args.join(' ')}`; reject( new Error(`Command failed (${commandString}): ${stderr.trim() || `exit code ${code}`}`), ); diff --git a/package.json b/package.json index 9f4cdb0..7f0dd81 100644 --- a/package.json +++ b/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": "bun scripts/build-yomitan.mjs", + "build:assets": "bun 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:immersion:sqlite": "bun run tsc && 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: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 && 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: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 && bun 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" } ] } diff --git a/plugin/subminer.conf b/plugin/subminer.conf index 9e81fe7..5bfdd41 100644 --- a/plugin/subminer.conf +++ b/plugin/subminer.conf @@ -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 diff --git a/plugin/subminer/binary.lua b/plugin/subminer/binary.lua index 5a065c5..49bfba3 100644 --- a/plugin/subminer/binary.lua +++ b/plugin/subminer/binary.lua @@ -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,34 @@ 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 = {} + + if environment.is_windows() then + 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") + else + 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(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") + end for _, path in ipairs(search_paths) do if file_exists(path) then diff --git a/plugin/subminer/bootstrap.lua b/plugin/subminer/bootstrap.lua index 8ee85d7..62eaabf 100644 --- a/plugin/subminer/bootstrap.lua +++ b/plugin/subminer/bootstrap.lua @@ -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") diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 721724b..c94e2d5 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -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() diff --git a/plugin/subminer/main.lua b/plugin/subminer/main.lua index 6f136ef..62ed65f 100644 --- a/plugin/subminer/main.lua +++ b/plugin/subminer/main.lua @@ -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 diff --git a/plugin/subminer/options.lua b/plugin/subminer/options.lua index 9601d74..e23ba53 100644 --- a/plugin/subminer/options.lua +++ b/plugin/subminer/options.lua @@ -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 diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 0bb0ea5..3d042ac 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -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, diff --git a/scripts/build-yomitan.mjs b/scripts/build-yomitan.mjs new file mode 100644 index 0000000..40d0e91 --- /dev/null +++ b/scripts/build-yomitan.mjs @@ -0,0 +1,163 @@ +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 bunCommand = process.versions.bun ? process.execPath : 'bun'; +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 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(bunCommand, ['install', '--no-save'], submoduleDir); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync(dependencyStampPath, `${currentLockHash}\n`, 'utf8'); + } +} + +function installAndBuild() { + ensureDependenciesInstalled(); + run(bunCommand, ['./dev/bin/build.js', '--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(); diff --git a/scripts/configure-plugin-binary-path.mjs b/scripts/configure-plugin-binary-path.mjs new file mode 100644 index 0000000..e4773a9 --- /dev/null +++ b/scripts/configure-plugin-binary-path.mjs @@ -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}`); diff --git a/scripts/get-mpv-window-windows.ps1 b/scripts/get-mpv-window-windows.ps1 new file mode 100644 index 0000000..2c5ef79 --- /dev/null +++ b/scripts/get-mpv-window-windows.ps1 @@ -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 + } + } + + $mpvMatches = 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 + } + + $mpvMatches.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 = $mpvMatches | Where-Object { $_.IsForeground } | Select-Object -First 1 + if ($null -ne $focusedMatch) { + [Console]::Error.WriteLine('focus=focused') + } else { + [Console]::Error.WriteLine('focus=not-focused') + } + + if ($mpvMatches.Count -eq 0) { + Write-Output 'not-found' + exit 0 + } + + $bestMatch = if ($null -ne $focusedMatch) { + $focusedMatch + } else { + $mpvMatches | 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 +} diff --git a/scripts/prepare-build-assets.mjs b/scripts/prepare-build-assets.mjs new file mode 100644 index 0000000..38cad99 --- /dev/null +++ b/scripts/prepare-build-assets.mjs @@ -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(); diff --git a/scripts/prettier-scope.sh b/scripts/prettier-scope.sh index fda0913..023af77 100644 --- a/scripts/prettier-scope.sh +++ b/scripts/prettier-scope.sh @@ -17,4 +17,5 @@ paths=( "src" ) -exec bunx prettier "$@" "${paths[@]}" +BUN_BIN="$(command -v bun.exe || command -v bun)" +exec "$BUN_BIN" x prettier "$@" "${paths[@]}" diff --git a/scripts/run-test-lane.mjs b/scripts/run-test-lane.mjs index 23c7cc2..d9179bb 100644 --- a/scripts/run-test-lane.mjs +++ b/scripts/run-test-lane.mjs @@ -1,8 +1,9 @@ import { readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { relative, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; -const repoRoot = resolve(new URL('..', import.meta.url).pathname); +const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); const lanes = { 'bun-src-full': { diff --git a/scripts/test-plugin-binary-windows.lua b/scripts/test-plugin-binary-windows.lua new file mode 100644 index 0000000..c04e77f --- /dev/null +++ b/scripts/test-plugin-binary-windows.lua @@ -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") diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 9e5bcac..b05c765 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -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") diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index c0bc5d0..0730ddb 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -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); diff --git a/src/cli/args.ts b/src/cli/args.ts index a10acf0..fb1650f 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -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; diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts index 462707d..7638f8d 100644 --- a/src/cli/help.test.ts +++ b/src/cli/help.test.ts @@ -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/); diff --git a/src/cli/help.ts b/src/cli/help.ts index 3b92880..9cf55bb 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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} diff --git a/src/config/path-resolution.test.ts b/src/config/path-resolution.test.ts index abd06cd..6be09f9 100644 --- a/src/config/path-resolution.test.ts +++ b/src/config/path-resolution.test.ts @@ -10,19 +10,32 @@ 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.posix.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', () => { const homeDir = '/home/tester'; const xdgConfigHome = '/tmp/xdg-config'; - const configDir = path.join(xdgConfigHome, 'SubMiner'); - const existsSync = existsSyncFrom([path.join(configDir, 'config.jsonc')]); + const configDir = path.posix.join(xdgConfigHome, 'SubMiner'); + const existsSync = existsSyncFrom([path.posix.join(configDir, 'config.jsonc')]); const resolved = resolveConfigDir({ xdgConfigHome, homeDir, + platform: 'linux', existsSync, }); @@ -37,20 +50,22 @@ 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.posix.join('/tmp/missing-xdg', 'SubMiner')); }); test('resolveConfigDir falls back to existing directory when file is missing', () => { const homeDir = '/home/tester'; - const configDir = path.join(homeDir, '.config', 'SubMiner'); + const configDir = path.posix.join(homeDir, '.config', 'SubMiner'); const existsSync = existsSyncFrom([configDir]); const resolved = resolveConfigDir({ xdgConfigHome: '/tmp/missing-xdg', homeDir, + platform: 'linux', existsSync, }); @@ -61,17 +76,18 @@ test('resolveConfigFilePath prefers jsonc before json', () => { const homeDir = '/home/tester'; const xdgConfigHome = '/tmp/xdg-config'; const existsSync = existsSyncFrom([ - path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), - path.join(xdgConfigHome, 'SubMiner', 'config.json'), + path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), + path.posix.join(xdgConfigHome, 'SubMiner', 'config.json'), ]); const resolved = resolveConfigFilePath({ xdgConfigHome, homeDir, + platform: 'linux', existsSync, }); - assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc')); + assert.equal(resolved, path.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc')); }); test('resolveConfigFilePath keeps legacy fallback output path', () => { @@ -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')); + assert.equal(resolved, path.posix.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.win32.join(appDataDir, 'SubMiner'); + const existsSync = existsSyncFrom([path.win32.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.win32.join(appDataDir, 'SubMiner', 'config.jsonc')); }); diff --git a/src/config/path-resolution.ts b/src/config/path-resolution.ts index ddd7469..ad123c0 100644 --- a/src/config/path-resolution.ts +++ b/src/config/path-resolution.ts @@ -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; @@ -13,11 +15,24 @@ type ConfigPathOptions = { const DEFAULT_APP_NAMES = ['SubMiner'] as const; const DEFAULT_FILE_NAMES = ['config.jsonc', 'config.json'] as const; +function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 { + return platform === 'win32' ? path.win32 : path.posix; +} + export function resolveConfigBaseDirs( xdgConfigHome: string | undefined, homeDir: string, + platform: NodeJS.Platform = process.platform, + appDataDir?: string, ): string[] { - const fallbackBaseDir = path.join(homeDir, '.config'); + const platformPath = getPlatformPath(platform); + if (platform === 'win32') { + const roamingBaseDir = platformPath.join(homeDir, 'AppData', 'Roaming'); + const primaryBaseDir = appDataDir?.trim() || roamingBaseDir; + return Array.from(new Set([primaryBaseDir, roamingBaseDir])); + } + + const fallbackBaseDir = platformPath.join(homeDir, '.config'); const primaryBaseDir = xdgConfigHome?.trim() || fallbackBaseDir; return Array.from(new Set([primaryBaseDir, fallbackBaseDir])); } @@ -31,14 +46,21 @@ function getDefaultAppName(options: ConfigPathOptions): string { } export function resolveConfigDir(options: ConfigPathOptions): string { - const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir); + const platform = options.platform ?? process.platform; + const platformPath = getPlatformPath(platform); + const baseDirs = resolveConfigBaseDirs( + options.xdgConfigHome, + options.homeDir, + platform, + options.appDataDir, + ); const appNames = getAppNames(options); for (const baseDir of baseDirs) { for (const appName of appNames) { - const dir = path.join(baseDir, appName); + const dir = platformPath.join(baseDir, appName); for (const fileName of DEFAULT_FILE_NAMES) { - if (options.existsSync(path.join(dir, fileName))) { + if (options.existsSync(platformPath.join(dir, fileName))) { return dir; } } @@ -47,24 +69,31 @@ export function resolveConfigDir(options: ConfigPathOptions): string { for (const baseDir of baseDirs) { for (const appName of appNames) { - const dir = path.join(baseDir, appName); + const dir = platformPath.join(baseDir, appName); if (options.existsSync(dir)) { return dir; } } } - return path.join(baseDirs[0]!, getDefaultAppName(options)); + return platformPath.join(baseDirs[0]!, getDefaultAppName(options)); } export function resolveConfigFilePath(options: ConfigPathOptions): string { - const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir); + const platform = options.platform ?? process.platform; + const platformPath = getPlatformPath(platform); + const baseDirs = resolveConfigBaseDirs( + options.xdgConfigHome, + options.homeDir, + platform, + options.appDataDir, + ); const appNames = getAppNames(options); for (const baseDir of baseDirs) { for (const appName of appNames) { for (const fileName of DEFAULT_FILE_NAMES) { - const candidate = path.join(baseDir, appName, fileName); + const candidate = platformPath.join(baseDir, appName, fileName); if (options.existsSync(candidate)) { return candidate; } @@ -72,5 +101,5 @@ export function resolveConfigFilePath(options: ConfigPathOptions): string { } } - return path.join(baseDirs[0]!, getDefaultAppName(options), DEFAULT_FILE_NAMES[0]!); + return platformPath.join(baseDirs[0]!, getDefaultAppName(options), DEFAULT_FILE_NAMES[0]!); } diff --git a/src/config/template.ts b/src/config/template.ts index 49bf9da..6c07f72 100644 --- a/src/config/template.ts +++ b/src/config/template.ts @@ -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('{'); diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 8a9cc0a..61862f0 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -7,6 +7,8 @@ function makeArgs(overrides: Partial = {}): CliArgs { return { background: false, start: false, + launchMpv: false, + launchMpvTargets: [], stop: false, toggle: false, toggleVisibleOverlay: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 58dec2b..90a2f83 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -7,6 +7,8 @@ function makeArgs(overrides: Partial = {}): CliArgs { return { background: false, start: false, + launchMpv: false, + launchMpvTargets: [], stop: false, toggle: false, toggleVisibleOverlay: false, diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 99b603e..d5bad4e 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -33,9 +33,30 @@ function makeDbPath(): string { function cleanupDbPath(dbPath: string): void { const dir = path.dirname(dbPath); - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); + if (!fs.existsSync(dir)) { + return; } + + const bunRuntime = globalThis as typeof globalThis & { + Bun?: { + gc?: (force?: boolean) => void; + }; + }; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + return; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (process.platform !== 'win32' || err.code !== 'EBUSY') { + throw error; + } + bunRuntime.Bun?.gc?.(true); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); + } + } + + // libsql keeps Windows file handles alive after close when prepared statements were used. } test('seam: resolveBoundedInt keeps fallback for invalid values', () => { diff --git a/src/core/services/immersion-tracker/storage-session.test.ts b/src/core/services/immersion-tracker/storage-session.test.ts index ad12515..b07d5ec 100644 --- a/src/core/services/immersion-tracker/storage-session.test.ts +++ b/src/core/services/immersion-tracker/storage-session.test.ts @@ -20,9 +20,30 @@ function makeDbPath(): string { function cleanupDbPath(dbPath: string): void { const dir = path.dirname(dbPath); - if (fs.existsSync(dir)) { - fs.rmSync(dir, { recursive: true, force: true }); + if (!fs.existsSync(dir)) { + return; } + + const bunRuntime = globalThis as typeof globalThis & { + Bun?: { + gc?: (force?: boolean) => void; + }; + }; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + return; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (process.platform !== 'win32' || err.code !== 'EBUSY') { + throw error; + } + bunRuntime.Bun?.gc?.(true); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); + } + } + + // libsql keeps Windows file handles alive after close when prepared statements were used. } test('ensureSchema creates immersion core tables', () => { diff --git a/src/core/services/overlay-runtime-init.ts b/src/core/services/overlay-runtime-init.ts index 77887d6..b295413 100644 --- a/src/core/services/overlay-runtime-init.ts +++ b/src/core/services/overlay-runtime-init.ts @@ -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()) { diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index e104c42..c640a50 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -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; diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index 9bd2e8b..b03c371 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -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(); } diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index 22f519c..1262789 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -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(); @@ -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(); + } } }); diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index f500060..1e89903 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -7,6 +7,8 @@ function makeArgs(overrides: Partial = {}): CliArgs { return { background: false, start: false, + launchMpv: false, + launchMpvTargets: [], stop: false, toggle: false, toggleVisibleOverlay: false, diff --git a/src/core/services/subsync.test.ts b/src/core/services/subsync.test.ts index 28cb7c6..3eaa41d 100644 --- a/src/core/services/subsync.test.ts +++ b/src/core/services/subsync.test.ts @@ -147,6 +147,28 @@ function writeExecutableScript(filePath: string, content: string): void { fs.chmodSync(filePath, 0o755); } +function toShellPath(filePath: string): string { + if (process.platform !== 'win32') { + return filePath; + } + + return filePath.replace(/\\/g, '/').replace(/^([A-Za-z]):\//, (_, driveLetter: string) => { + return `/mnt/${driveLetter.toLowerCase()}/`; + }); +} + +function fromShellPath(filePath: string): string { + if (process.platform !== 'win32') { + return filePath; + } + + return filePath + .replace(/^\/mnt\/([a-z])\//, (_, driveLetter: string) => { + return `${driveLetter.toUpperCase()}:/`; + }) + .replace(/\//g, '\\'); +} + test('runSubsyncManual constructs ffsubsync command and returns success', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-ffsubsync-')); const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log'); @@ -162,7 +184,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n'); writeExecutableScript( ffsubsyncPath, - `#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`, + `#!/bin/sh\n: > "${toShellPath(ffsubsyncLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(ffsubsyncLogPath)}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`, ); const sentCommands: Array> = []; @@ -204,14 +226,14 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async assert.equal(result.ok, true); assert.equal(result.message, 'Subtitle synchronized with ffsubsync'); const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n'); - assert.equal(ffArgs[0], videoPath); + assert.equal(ffArgs[0], toShellPath(videoPath)); assert.ok(ffArgs.includes('-i')); - assert.ok(ffArgs.includes(primaryPath)); + assert.ok(ffArgs.includes(toShellPath(primaryPath))); assert.ok(ffArgs.includes('--reference-stream')); assert.ok(ffArgs.includes('0:2')); const ffOutputFlagIndex = ffArgs.indexOf('-o'); assert.equal(ffOutputFlagIndex >= 0, true); - assert.equal(ffArgs[ffOutputFlagIndex + 1], primaryPath); + assert.equal(ffArgs[ffOutputFlagIndex + 1], toShellPath(primaryPath)); assert.equal(sentCommands[0]?.[0], 'sub_add'); assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]); }); @@ -231,7 +253,7 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n'); writeExecutableScript( ffsubsyncPath, - `#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`, + `#!/bin/sh\n: > "${toShellPath(ffsubsyncLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(ffsubsyncLogPath)}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`, ); const deps = makeDeps({ @@ -273,7 +295,7 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa const ffOutputFlagIndex = ffArgs.indexOf('-o'); assert.equal(ffOutputFlagIndex >= 0, true); const outputPath = ffArgs[ffOutputFlagIndex + 1]; - assert.equal(outputPath, path.join(tmpDir, 'episode.ja_retimed.srt')); + assert.equal(outputPath, toShellPath(path.join(tmpDir, 'episode.ja_retimed.srt'))); }); test('runSubsyncManual reports ffsubsync command failures with details', async () => { @@ -346,7 +368,7 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n'); writeExecutableScript( alassPath, - `#!/bin/sh\n: > "${alassLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${alassLogPath}"; done\nexit 1\n`, + `#!/bin/sh\n: > "${toShellPath(alassLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(alassLogPath)}"; done\nexit 1\n`, ); const deps = makeDeps({ @@ -393,8 +415,8 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero assert.equal(typeof result.message, 'string'); assert.equal(result.message.startsWith('alass synchronization failed'), true); const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n'); - assert.equal(alassArgs[0], sourcePath); - assert.equal(alassArgs[1], primaryPath); + assert.equal(alassArgs[0], toShellPath(sourcePath)); + assert.equal(alassArgs[1], toShellPath(primaryPath)); }); test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => { @@ -482,7 +504,7 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n'); writeExecutableScript( ffsubsyncPath, - `#!/bin/sh\nmkdir -p "${tmpDir}"\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nprev=""\nout=""\nfor arg in "$@"; do\n if [ "$prev" = "--reference-stream" ]; then :; fi\n if [ "$prev" = "-o" ]; then out="$arg"; fi\n prev="$arg"\ndone\nif [ -n "$out" ]; then : > "$out"; fi`, + `#!/bin/sh\nmkdir -p "${toShellPath(tmpDir)}"\n: > "${toShellPath(ffsubsyncLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(ffsubsyncLogPath)}"; done\nprev=""\nout=""\nfor arg in "$@"; do\n if [ "$prev" = "--reference-stream" ]; then :; fi\n if [ "$prev" = "-o" ]; then out="$arg"; fi\n prev="$arg"\ndone\nif [ -n "$out" ]; then : > "$out"; fi`, ); const deps = makeDeps({ @@ -526,5 +548,5 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a const outputPath = ffArgs[syncOutputIndex + 1]; assert.equal(typeof outputPath, 'string'); assert.ok(outputPath!.length > 0); - assert.equal(fs.readFileSync(outputPath!, 'utf8'), ''); + assert.equal(fs.readFileSync(fromShellPath(outputPath!), 'utf8'), ''); }); diff --git a/src/core/services/yomitan-extension-paths.test.ts b/src/core/services/yomitan-extension-paths.test.ts index f198d14..a105861 100644 --- a/src/core/services/yomitan-extension-paths.test.ts +++ b/src/core/services/yomitan-extension-paths.test.ts @@ -8,18 +8,21 @@ import { } from './yomitan-extension-paths'; test('getYomitanExtensionSearchPaths prioritizes generated build output before packaged fallbacks', () => { + const repoRoot = path.resolve('repo'); + const resourcesPath = path.join(path.sep, 'opt', 'SubMiner', 'resources'); + const userDataPath = path.join(path.sep, 'Users', 'kyle', '.config', 'SubMiner'); const searchPaths = getYomitanExtensionSearchPaths({ - cwd: '/repo', - moduleDir: '/repo/dist/core/services', - resourcesPath: '/opt/SubMiner/resources', - userDataPath: '/Users/kyle/.config/SubMiner', + cwd: repoRoot, + moduleDir: path.join(repoRoot, 'dist', 'core', 'services'), + resourcesPath, + userDataPath, }); assert.deepEqual(searchPaths, [ - path.join('/repo', 'build', 'yomitan'), - path.join('/opt/SubMiner/resources', 'yomitan'), + path.join(repoRoot, 'build', 'yomitan'), + path.join(resourcesPath, 'yomitan'), '/usr/share/SubMiner/yomitan', - path.join('/Users/kyle/.config/SubMiner', 'yomitan'), + path.join(userDataPath, 'yomitan'), ]); }); diff --git a/src/logger.test.ts b/src/logger.test.ts new file mode 100644 index 0000000..58eacf5 --- /dev/null +++ b/src/logger.test.ts @@ -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`, + ), + ); +}); diff --git a/src/logger.ts b/src/logger.ts index 3092095..befb924 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -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 { diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index 4d21fe3..3235077 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -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', diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index 0ca555e..a58f831 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -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'; diff --git a/src/main-entry.ts b/src/main-entry.ts index d31b871..c99e6de 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -1,13 +1,19 @@ 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 { requestSingleInstanceLockEarly } from './main/early-single-instance'; +import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; const DEFAULT_TEXTHOOKER_PORT = 5174; @@ -46,4 +52,25 @@ 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 { + const gotSingleInstanceLock = requestSingleInstanceLockEarly(app); + if (!gotSingleInstanceLock) { + app.exit(0); + } + require('./main.js'); +} diff --git a/src/main.ts b/src/main.ts index bc03bd0..2b48184 100644 --- a/src/main.ts +++ b/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'; @@ -344,6 +350,10 @@ import { } from './main/runtime/composers'; import { createStartupBootstrapRuntimeDeps } from './main/startup'; import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle'; +import { + registerSecondInstanceHandlerEarly, + requestSingleInstanceLockEarly, +} from './main/early-single-instance'; import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command'; import { registerIpcRuntimeServices } from './main/ipc-runtime'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; @@ -362,6 +372,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 +415,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 +480,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 +500,7 @@ const configService = (() => { { logError: (details) => console.error(details), showErrorBox: (title, details) => dialog.showErrorBox(title, details), - quit: () => app.quit(), + quit: () => requestAppQuit(), }, ); } @@ -552,6 +572,22 @@ const appLogger = { }, }; const runtimeRegistry = createMainRuntimeRegistry(); +const appLifecycleApp = { + requestSingleInstanceLock: () => requestSingleInstanceLockEarly(app), + quit: () => app.quit(), + on: (event: string, listener: (...args: unknown[]) => void) => { + if (event === 'second-instance') { + registerSecondInstanceHandlerEarly( + app, + listener as (_event: unknown, argv: string[]) => void, + ); + return app; + } + app.on(event as Parameters[0], listener as (...args: any[]) => void); + return app; + }, + whenReady: () => app.whenReady(), +}; const buildGetDefaultSocketPathMainDepsHandler = createBuildGetDefaultSocketPathMainDepsHandler({ platform: process.platform, @@ -568,11 +604,23 @@ if (!fs.existsSync(USER_DATA_PATH)) { } app.setPath('userData', USER_DATA_PATH); -process.on('SIGINT', () => { +let forceQuitTimer: ReturnType | 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 +671,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 +704,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 +1048,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 +1173,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 +1389,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( overlayShortcutsRuntime.syncOverlayShortcuts(); }, isMacOSPlatform: () => process.platform === 'darwin', + isWindowsPlatform: () => process.platform === 'win32', showOverlayLoadingOsd: (message: string) => { showMpvOsd(message); }, @@ -1687,28 +1785,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 +1838,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ await firstRunSetupService.markSetupCancelled(); }, isSetupCompleted: () => firstRunSetupService.isSetupCompleted(), + shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode, + quitApp: () => requestAppQuit(), clearSetupWindow: () => { appState.firstRunSetupWindow = null; }, @@ -2151,7 +2260,7 @@ const { app.on('open-url', listener); }, registerSecondInstance: (listener) => { - app.on('second-instance', listener); + registerSecondInstanceHandlerEarly(app, listener); }, handleAnilistSetupProtocolUrl: (rawUrl) => handleAnilistSetupProtocolUrl(rawUrl), findAnilistSetupDeepLinkArgvUrl: (argv) => findAnilistSetupDeepLinkArgvUrl(argv), @@ -2202,6 +2311,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 +2383,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 +2391,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: { @@ -2432,7 +2543,7 @@ const { runAndApplyStartupState } = runtimeRegistry.startup.createStartupRuntime ReturnType >({ appLifecycleRuntimeRunnerMainDeps: { - app, + app: appLifecycleApp, platform: process.platform, shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs), parseArgs: (argv: string[]) => parseArgs(argv), @@ -2476,7 +2587,7 @@ const { runAndApplyStartupState } = runtimeRegistry.startup.createStartupRuntime setExitCode: (code) => { process.exitCode = code; }, - quitApp: () => app.quit(), + quitApp: () => requestAppQuit(), logGenerateConfigError: (message) => logger.error(message), startAppLifecycle, }), @@ -2510,6 +2621,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,10 +2638,10 @@ const { createMpvClientRuntimeService: createMpvClientRuntimeServiceHandler, updateMpvSubtitleRenderMetrics: updateMpvSubtitleRenderMetricsHandler, tokenizeSubtitle, - isTokenizationWarmupReady, createMecabTokenizerAndCheck, prewarmSubtitleDictionaries, startBackgroundWarmups, + isTokenizationWarmupReady, } = composeMpvRuntimeHandlers< MpvIpcClient, ReturnType, @@ -2541,7 +2653,7 @@ const { scheduleQuitCheck: (callback) => { setTimeout(callback, 500); }, - quitApp: () => app.quit(), + quitApp: () => requestAppQuit(), reportJellyfinRemoteStopped: () => { void reportJellyfinRemoteStopped(); }, @@ -2566,12 +2678,6 @@ const { } mediaRuntime.updateCurrentMediaPath(path); }, - signalAutoplayReadyIfWarm: (path) => { - if (!isTokenizationWarmupReady()) { - return; - } - maybeSignalPluginAutoplayReady({ text: path, tokens: null }, { forceWhilePaused: true }); - }, restoreMpvSubVisibility: () => { restoreOverlayMpvSubtitles(); }, @@ -2588,6 +2694,15 @@ const { syncImmersionMediaState: () => { immersionMediaRuntime.syncFromCurrentMediaState(); }, + signalAutoplayReadyIfWarm: () => { + if (!isTokenizationWarmupReady()) { + return; + } + maybeSignalPluginAutoplayReady( + { text: '__warm__', tokens: null }, + { forceWhilePaused: true }, + ); + }, scheduleCharacterDictionarySync: () => { characterDictionaryAutoSyncRuntime.scheduleSync(); }, @@ -2849,13 +2964,7 @@ async function ensureYomitanExtensionLoaded(): Promise { 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 +3003,7 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise { }, }, { - forceOverride: getResolvedConfig().ankiConnect.proxy?.enabled === true, + forceOverride: shouldForceOverrideYomitanAnkiServer(getResolvedConfig().ankiConnect), }, ); @@ -3244,7 +3353,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 +3454,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 +3504,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, diff --git a/src/main/early-single-instance.test.ts b/src/main/early-single-instance.test.ts new file mode 100644 index 0000000..48123e3 --- /dev/null +++ b/src/main/early-single-instance.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + registerSecondInstanceHandlerEarly, + requestSingleInstanceLockEarly, + resetEarlySingleInstanceStateForTests, +} from './early-single-instance'; + +function createFakeApp(lockValue = true) { + let requestCalls = 0; + let secondInstanceListener: ((_event: unknown, argv: string[]) => void) | null = null; + + return { + app: { + requestSingleInstanceLock: () => { + requestCalls += 1; + return lockValue; + }, + on: (_event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => { + secondInstanceListener = listener; + }, + }, + emitSecondInstance: (argv: string[]) => { + secondInstanceListener?.({}, argv); + }, + getRequestCalls: () => requestCalls, + }; +} + +test('requestSingleInstanceLockEarly caches the lock result per process', () => { + resetEarlySingleInstanceStateForTests(); + const fake = createFakeApp(true); + + assert.equal(requestSingleInstanceLockEarly(fake.app), true); + assert.equal(requestSingleInstanceLockEarly(fake.app), true); + assert.equal(fake.getRequestCalls(), 1); +}); + +test('registerSecondInstanceHandlerEarly replays queued argv and forwards new events', () => { + resetEarlySingleInstanceStateForTests(); + const fake = createFakeApp(true); + const calls: string[][] = []; + + assert.equal(requestSingleInstanceLockEarly(fake.app), true); + fake.emitSecondInstance(['SubMiner.exe', '--start', '--socket', '\\\\.\\pipe\\subminer']); + + registerSecondInstanceHandlerEarly(fake.app, (_event, argv) => { + calls.push(argv); + }); + fake.emitSecondInstance(['SubMiner.exe', '--start', '--show-visible-overlay']); + + assert.deepEqual(calls, [ + ['SubMiner.exe', '--start', '--socket', '\\\\.\\pipe\\subminer'], + ['SubMiner.exe', '--start', '--show-visible-overlay'], + ]); +}); diff --git a/src/main/early-single-instance.ts b/src/main/early-single-instance.ts new file mode 100644 index 0000000..89636f9 --- /dev/null +++ b/src/main/early-single-instance.ts @@ -0,0 +1,54 @@ +interface ElectronSecondInstanceAppLike { + requestSingleInstanceLock: () => boolean; + on: ( + event: 'second-instance', + listener: (_event: unknown, argv: string[]) => void, + ) => unknown; +} + +let cachedSingleInstanceLock: boolean | null = null; +let secondInstanceListenerAttached = false; +const secondInstanceArgvHistory: string[][] = []; +const secondInstanceHandlers = new Set<(_event: unknown, argv: string[]) => void>(); + +function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void { + if (secondInstanceListenerAttached) return; + app.on('second-instance', (event, argv) => { + const clonedArgv = [...argv]; + secondInstanceArgvHistory.push(clonedArgv); + for (const handler of secondInstanceHandlers) { + handler(event, [...clonedArgv]); + } + }); + secondInstanceListenerAttached = true; +} + +export function requestSingleInstanceLockEarly(app: ElectronSecondInstanceAppLike): boolean { + attachSecondInstanceListener(app); + if (cachedSingleInstanceLock !== null) { + return cachedSingleInstanceLock; + } + cachedSingleInstanceLock = app.requestSingleInstanceLock(); + return cachedSingleInstanceLock; +} + +export function registerSecondInstanceHandlerEarly( + app: ElectronSecondInstanceAppLike, + handler: (_event: unknown, argv: string[]) => void, +): () => void { + attachSecondInstanceListener(app); + secondInstanceHandlers.add(handler); + for (const argv of secondInstanceArgvHistory) { + handler(undefined, [...argv]); + } + return () => { + secondInstanceHandlers.delete(handler); + }; +} + +export function resetEarlySingleInstanceStateForTests(): void { + cachedSingleInstanceLock = null; + secondInstanceListenerAttached = false; + secondInstanceArgvHistory.length = 0; + secondInstanceHandlers.clear(); +} diff --git a/src/main/overlay-shortcuts-runtime.ts b/src/main/overlay-shortcuts-runtime.ts index 756fb59..4b4e3ae 100644 --- a/src/main/overlay-shortcuts-runtime.ts +++ b/src/main/overlay-shortcuts-runtime.ts @@ -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) => diff --git a/src/main/overlay-visibility-runtime.ts b/src/main/overlay-visibility-runtime.ts index 46db4cd..94cba27 100644 --- a/src/main/overlay-visibility-runtime.ts +++ b/src/main/overlay-visibility-runtime.ts @@ -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(), }); diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index 4c92450..0a97ac9 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -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')); diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index 28fbe8e..f064807 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -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(); }; diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index bc3702d..b04cd4f 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -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: () => {}, }); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index b8b073c..f2d5c8e 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -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(), }); diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index 92f95b1..bd04f71 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -35,6 +35,10 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler clearAnilistSetupWindow: () => {}, getJellyfinSetupWindow: () => null, clearJellyfinSetupWindow: () => {}, + getFirstRunSetupWindow: () => null, + clearFirstRunSetupWindow: () => {}, + getYomitanSettingsWindow: () => null, + clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: async () => {}, stopDiscordPresenceService: () => {}, }, diff --git a/src/main/runtime/dictionary-runtime-main-deps.test.ts b/src/main/runtime/dictionary-runtime-main-deps.test.ts index 330dea3..73e43db 100644 --- a/src/main/runtime/dictionary-runtime-main-deps.test.ts +++ b/src/main/runtime/dictionary-runtime-main-deps.test.ts @@ -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({ diff --git a/src/main/runtime/dictionary-runtime-main-deps.ts b/src/main/runtime/dictionary-runtime-main-deps.ts index 0cacd1d..0a9dfd2 100644 --- a/src/main/runtime/dictionary-runtime-main-deps.ts +++ b/src/main/runtime/dictionary-runtime-main-deps.ts @@ -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: { diff --git a/src/main/runtime/first-run-setup-plugin.test.ts b/src/main/runtime/first-run-setup-plugin.test.ts index 1570952..08c6c89 100644 --- a/src/main/runtime/first-run-setup-plugin.test.ts +++ b/src/main/runtime/first-run-setup-plugin.test.ts @@ -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,71 @@ 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', () => { + if (process.platform !== 'win32') { + return; + } + 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', () => { + if (process.platform !== 'win32') { + return; + } + 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', + ); + }); }); diff --git a/src/main/runtime/first-run-setup-plugin.ts b/src/main/runtime/first-run-setup-plugin.ts index 352f0d6..4fb37c1 100644 --- a/src/main/runtime/first-run-setup-plugin.ts +++ b/src/main/runtime/first-run-setup-plugin.ts @@ -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, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index 5bd63a1..ef7d0ce 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -21,6 +21,8 @@ function makeArgs(overrides: Partial = {}): CliArgs { return { background: false, start: false, + launchMpv: false, + launchMpvTargets: [], stop: false, toggle: false, toggleVisibleOverlay: false, @@ -169,3 +171,79 @@ 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); + }); +}); + +test('setup service persists Windows mpv shortcut preferences and status with one state write', async () => { + await withTempDir(async (root) => { + const configDir = path.join(root, 'SubMiner'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, 'config.jsonc'), '{}'); + const stateChanges: string[] = []; + + const service = createFirstRunSetupService({ + platform: 'win32', + configDir, + getYomitanDictionaryCount: async () => 0, + detectPluginInstalled: () => false, + installPlugin: async () => ({ + ok: true, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + message: 'ok', + }), + applyWindowsMpvShortcuts: async () => ({ + ok: true, + status: 'installed', + message: 'shortcuts updated', + }), + onStateChanged: (state) => { + stateChanges.push(state.windowsMpvShortcutLastStatus); + }, + }); + + await service.ensureSetupStateInitialized(); + stateChanges.length = 0; + + const snapshot = await service.configureWindowsMpvShortcuts({ + startMenuEnabled: false, + desktopEnabled: true, + }); + + assert.equal(snapshot.windowsMpvShortcuts.startMenuEnabled, false); + assert.equal(snapshot.windowsMpvShortcuts.desktopEnabled, true); + assert.equal(snapshot.state.windowsMpvShortcutLastStatus, 'installed'); + assert.equal(snapshot.message, 'shortcuts updated'); + assert.deepEqual(stateChanges, ['installed']); + }); +}); diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index 41e52c0..5541d7f 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -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; skipPluginInstall: () => Promise; installMpvPlugin: () => Promise; + configureWindowsMpvShortcuts: (preferences: { + startMenuEnabled: boolean; + desktopEnabled: boolean; + }) => Promise; 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; detectPluginInstalled: () => boolean | Promise; installPlugin: () => Promise; + 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,33 @@ export function createFirstRunSetupService(deps: { result.message, ); }, + configureWindowsMpvShortcuts: async (preferences) => { + if (!isWindows || !deps.applyWindowsMpvShortcuts) { + return refreshWithState( + writeState({ + ...readState(), + windowsMpvShortcutPreferences: { + startMenuEnabled: preferences.startMenuEnabled, + desktopEnabled: preferences.desktopEnabled, + }, + }), + null, + ); + } + const result = await deps.applyWindowsMpvShortcuts(preferences); + const latestState = readState(); + return refreshWithState( + writeState({ + ...latestState, + windowsMpvShortcutPreferences: { + startMenuEnabled: preferences.startMenuEnabled, + desktopEnabled: preferences.desktopEnabled, + }, + windowsMpvShortcutLastStatus: result.status, + }), + result.message, + ); + }, isSetupCompleted: () => completed || isSetupCompleted(readState()), }; } diff --git a/src/main/runtime/first-run-setup-window.test.ts b/src/main/runtime/first-run-setup-window.test.ts index d895847..34b300b 100644 --- a/src/main/runtime/first-run-setup-window.test.ts +++ b/src/main/runtime/first-run-setup-window.test.ts @@ -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: () => '', + 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']); +}); diff --git a/src/main/runtime/first-run-setup-window.ts b/src/main/runtime/first-run-setup-window.ts index 5c7e584..9f0b5e1 100644 --- a/src/main/runtime/first-run-setup-window.ts +++ b/src/main/runtime/first-run-setup-window.ts @@ -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 + ? ` +
+
+
+ Windows mpv launcher +
Create standalone \`SubMiner mpv\` shortcuts that run \`SubMiner.exe --launch-mpv\`.
+
Installed: Start Menu ${model.windowsMpvShortcuts.startMenuInstalled ? 'yes' : 'no'}, Desktop ${model.windowsMpvShortcuts.desktopInstalled ? 'yes' : 'no'}
+
+ ${renderStatusBadge(windowsShortcutLabel, windowsShortcutTone)} +
+
+ + + +
+
` + : ''; return ` @@ -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', )} + ${windowsShortcutCard}
@@ -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; + parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null; + handleAction: (submission: FirstRunSetupSubmission) => Promise; 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; 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; markSetupCancelled: () => Promise; 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 diff --git a/src/main/runtime/initial-args-handler.test.ts b/src/main/runtime/initial-args-handler.test.ts index ef836c0..3a72302 100644 --- a/src/main/runtime/initial-args-handler.test.ts +++ b/src/main/runtime/initial-args-handler.test.ts @@ -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); +}); diff --git a/src/main/runtime/initial-args-handler.ts b/src/main/runtime/initial-args-handler.ts index 2dcc02e..dac3ae1 100644 --- a/src/main/runtime/initial-args-handler.ts +++ b/src/main/runtime/initial-args-handler.ts @@ -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(); } diff --git a/src/main/runtime/initial-args-main-deps.test.ts b/src/main/runtime/initial-args-main-deps.test.ts index efd3fc3..ab7d6c9 100644 --- a/src/main/runtime/initial-args-main-deps.test.ts +++ b/src/main/runtime/initial-args-main-deps.test.ts @@ -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); diff --git a/src/main/runtime/initial-args-main-deps.ts b/src/main/runtime/initial-args-main-deps.ts index f0b7a64..96670c9 100644 --- a/src/main/runtime/initial-args-main-deps.ts +++ b/src/main/runtime/initial-args-main-deps.ts @@ -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(), diff --git a/src/main/runtime/initial-args-runtime-handler.test.ts b/src/main/runtime/initial-args-runtime-handler.test.ts index 44d9242..86f77fc 100644 --- a/src/main/runtime/initial-args-runtime-handler.test.ts +++ b/src/main/runtime/initial-args-runtime-handler.test.ts @@ -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, diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 71e3a10..0ed1108 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -48,6 +48,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), syncImmersionMediaState: () => calls.push('sync-immersion'), + signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`), updateCurrentMediaTitle: (title) => calls.push(`title:${title}`), resetAnilistMediaGuessState: () => calls.push('reset-guess'), reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`), @@ -82,6 +83,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as deps.maybeProbeAnilistDuration('media-key'); deps.ensureAnilistMediaGuess('media-key'); deps.syncImmersionMediaState(); + deps.signalAutoplayReadyIfWarm('/tmp/video'); deps.updateCurrentMediaTitle('title'); deps.resetAnilistMediaGuessState(); deps.notifyImmersionTitleUpdate('title'); @@ -100,6 +102,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as assert.ok(calls.includes('anilist-post-watch')); assert.ok(calls.includes('ensure-immersion')); assert.ok(calls.includes('sync-immersion')); + assert.ok(calls.includes('autoplay:/tmp/video')); assert.ok(calls.includes('metrics')); assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('restore-mpv-sub')); diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts index 1287271..69c1126 100644 --- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts @@ -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); diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts index 5915fe4..b6eb9b1 100644 --- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts @@ -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(), diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts index ba4a823..9ee9680 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts @@ -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); diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts index 72c7024..78c4039 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts @@ -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(), }); diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index 6293d69..bffc375 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -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', diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index c3b6f0e..c38bf23 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -31,6 +31,8 @@ export function createBuildTrayMenuTemplateHandler(deps: { openOverlay: () => void; openFirstRunSetup: () => void; showFirstRunSetup: boolean; + openWindowsMpvLauncherSetup: () => void; + showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; openRuntimeOptions: () => void; openJellyfinSetup: () => void; @@ -42,6 +44,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { setVisibleOverlayVisible: (visible: boolean) => void; showFirstRunSetup: () => boolean; openFirstRunSetupWindow: () => void; + showWindowsMpvLauncherSetup: () => boolean; openYomitanSettings: () => void; openRuntimeOptionsPalette: () => void; openJellyfinSetupWindow: () => void; @@ -60,6 +63,10 @@ export function createBuildTrayMenuTemplateHandler(deps: { deps.openFirstRunSetupWindow(); }, showFirstRunSetup: deps.showFirstRunSetup(), + openWindowsMpvLauncherSetup: () => { + deps.openFirstRunSetupWindow(); + }, + showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(), openYomitanSettings: () => { deps.openYomitanSettings(); }, diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index 644c358..d33ab8c 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -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'), diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index 3e37a6b..57e601b 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -30,6 +30,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { openOverlay: () => void; openFirstRunSetup: () => void; showFirstRunSetup: boolean; + openWindowsMpvLauncherSetup: () => void; + showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; openRuntimeOptions: () => void; openJellyfinSetup: () => void; @@ -41,6 +43,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { setVisibleOverlayVisible: (visible: boolean) => void; showFirstRunSetup: () => boolean; openFirstRunSetupWindow: () => void; + showWindowsMpvLauncherSetup: () => boolean; openYomitanSettings: () => void; openRuntimeOptionsPalette: () => void; openJellyfinSetupWindow: () => void; @@ -54,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { setVisibleOverlayVisible: deps.setVisibleOverlayVisible, showFirstRunSetup: deps.showFirstRunSetup, openFirstRunSetupWindow: deps.openFirstRunSetupWindow, + showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup, openYomitanSettings: deps.openYomitanSettings, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, openJellyfinSetupWindow: deps.openJellyfinSetupWindow, diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts index 7d8df2b..16c1cc3 100644 --- a/src/main/runtime/tray-runtime-handlers.test.ts +++ b/src/main/runtime/tray-runtime-handlers.test.ts @@ -29,6 +29,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () => }, showFirstRunSetup: () => true, openFirstRunSetupWindow: () => {}, + showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => {}, openRuntimeOptionsPalette: () => {}, openJellyfinSetupWindow: () => {}, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index 4ff92e9..f25ab59 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -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); }); diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index 5d7ad41..f6b3ec8 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -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, diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts new file mode 100644 index 0000000..e45c806 --- /dev/null +++ b/src/main/runtime/windows-mpv-launch.test.ts @@ -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 { + 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); +}); diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts new file mode 100644 index 0000000..8e3555a --- /dev/null +++ b/src/main/runtime/windows-mpv-launch.ts @@ -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, + }; +} diff --git a/src/main/runtime/windows-mpv-shortcuts.test.ts b/src/main/runtime/windows-mpv-shortcuts.test.ts new file mode 100644 index 0000000..a0c864f --- /dev/null +++ b/src/main/runtime/windows-mpv-shortcuts.test.ts @@ -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}|${operation}|${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|replace|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/); +}); diff --git a/src/main/runtime/windows-mpv-shortcuts.ts b/src/main/runtime/windows-mpv-shortcuts.ts new file mode 100644 index 0000000..4dc349b --- /dev/null +++ b/src/main/runtime/windows-mpv-shortcuts.ts @@ -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, 'replace', 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.', + }; +} diff --git a/src/main/runtime/yomitan-anki-server.test.ts b/src/main/runtime/yomitan-anki-server.test.ts new file mode 100644 index 0000000..9aa37b5 --- /dev/null +++ b/src/main/runtime/yomitan-anki-server.test.ts @@ -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 { + 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); +}); diff --git a/src/main/runtime/yomitan-anki-server.ts b/src/main/runtime/yomitan-anki-server.ts new file mode 100644 index 0000000..36ce6ec --- /dev/null +++ b/src/main/runtime/yomitan-anki-server.ts @@ -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; +} diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index 4073974..9916c5a 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -5,6 +5,8 @@ import { resolve } from 'node:path'; const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml'); const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8'); +const makefilePath = resolve(__dirname, '../Makefile'); +const makefile = readFileSync(makefilePath, 'utf8'); test('publish release leaves prerelease unset so gh creates a normal release', () => { assert.ok(!releaseWorkflow.includes('--prerelease')); @@ -18,3 +20,13 @@ test('release workflow generates release notes from committed changelog output', assert.match(releaseWorkflow, /bun run changelog:release-notes/); assert.ok(!releaseWorkflow.includes('git log --pretty=format:"- %s"')); }); + +test('release workflow includes the Windows installer in checksums and uploaded assets', () => { + assert.match(releaseWorkflow, /files=\(release\/\*\.AppImage release\/\*\.dmg release\/\*\.exe release\/\*\.zip release\/\*\.tar\.gz dist\/launcher\/subminer\)/); + assert.match(releaseWorkflow, /artifacts=\([\s\S]*release\/\*\.exe[\s\S]*release\/SHA256SUMS\.txt[\s\S]*\)/); +}); + +test('Makefile routes Windows install-plugin setup through bun and documents Windows builds', () => { + assert.match(makefile, /windows\) printf '%s\\n' "\[INFO\] Windows builds run via: bun run build:win" ;;/); + assert.match(makefile, /bun \.\/scripts\/configure-plugin-binary-path\.mjs/); +}); diff --git a/src/renderer/positioning/position-state.test.ts b/src/renderer/positioning/position-state.test.ts new file mode 100644 index 0000000..c8c9f10 --- /dev/null +++ b/src/renderer/positioning/position-state.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createInMemorySubtitlePositionController } from './position-state.js'; + +function withWindow(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'); + }, + ); +}); diff --git a/src/renderer/positioning/position-state.ts b/src/renderer/positioning/position-state.ts index c21ce8d..ac88d54 100644 --- a/src/renderer/positioning/position-state.ts +++ b/src/renderer/positioning/position-state.ts @@ -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) => 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); } diff --git a/src/shared/setup-state.test.ts b/src/shared/setup-state.test.ts index 4f409b8..97627f8 100644 --- a/src/shared/setup-state.test.ts +++ b/src/shared/setup-state.test.ts @@ -24,13 +24,17 @@ 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.posix.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), }); - assert.equal(dir, '/tmp/xdg/SubMiner'); + assert.equal(dir, path.posix.join(xdgConfigHome, 'SubMiner')); }); test('ensureDefaultConfigBootstrap creates config dir and default jsonc only when missing', () => { @@ -61,6 +65,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 +101,126 @@ 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.posix.join(xdgConfigHome, 'mpv'), + scriptsDir: path.posix.join(xdgConfigHome, 'mpv', 'scripts'), + scriptOptsDir: path.posix.join(xdgConfigHome, 'mpv', 'script-opts'), + pluginEntrypointPath: path.posix.join(xdgConfigHome, 'mpv', 'scripts', 'subminer', 'main.lua'), + pluginDir: path.posix.join(xdgConfigHome, 'mpv', 'scripts', 'subminer'), + pluginConfigPath: path.posix.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.posix.join(macHomeDir, 'Library', 'Application Support', 'mpv'), + scriptsDir: path.posix.join(macHomeDir, 'Library', 'Application Support', 'mpv', 'scripts'), + scriptOptsDir: path.posix.join( + macHomeDir, + 'Library', + 'Application Support', + 'mpv', + 'script-opts', + ), + pluginEntrypointPath: path.posix.join( + macHomeDir, + 'Library', + 'Application Support', + 'mpv', + 'scripts', + 'subminer', + 'main.lua', + ), + pluginDir: path.posix.join( + macHomeDir, + 'Library', + 'Application Support', + 'mpv', + 'scripts', + 'subminer', + ), + pluginConfigPath: path.posix.join( + macHomeDir, + 'Library', + 'Application Support', + 'mpv', + 'script-opts', + 'subminer.conf', + ), + }); + + assert.deepEqual(resolveDefaultMpvInstallPaths('win32', 'C:\\Users\\tester', undefined), { + supported: true, + mpvConfigDir: path.win32.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv'), + scriptsDir: path.win32.join('C:\\Users\\tester', 'AppData', 'Roaming', 'mpv', 'scripts'), + scriptOptsDir: path.win32.join( + 'C:\\Users\\tester', + 'AppData', + 'Roaming', + 'mpv', + 'script-opts', + ), + pluginEntrypointPath: path.win32.join( + 'C:\\Users\\tester', + 'AppData', + 'Roaming', + 'mpv', + 'scripts', + 'subminer', + 'main.lua', + ), + pluginDir: path.win32.join( + 'C:\\Users\\tester', + 'AppData', + 'Roaming', + 'mpv', + 'scripts', + 'subminer', + ), + pluginConfigPath: path.win32.join( + 'C:\\Users\\tester', + 'AppData', + 'Roaming', + 'mpv', + 'script-opts', + 'subminer.conf', + ), }); }); diff --git a/src/shared/setup-state.ts b/src/shared/setup-state.ts index ab9bc2b..c82cc99 100644 --- a/src/shared/setup-state.ts +++ b/src/shared/setup-state.ts @@ -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,10 +35,15 @@ export interface MpvInstallPaths { mpvConfigDir: string; scriptsDir: string; scriptOptsDir: string; + pluginEntrypointPath: string; pluginDir: string; pluginConfigPath: string; } +function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 { + return platform === 'win32' ? path.win32 : path.posix; +} + function asObject(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) @@ -39,25 +52,33 @@ function asObject(value: unknown): Record | 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 +87,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 +100,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 +113,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 +139,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 +208,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'); } @@ -177,19 +227,21 @@ export function resolveDefaultMpvInstallPaths( homeDir: string, xdgConfigHome?: string, ): MpvInstallPaths { + const platformPath = getPlatformPath(platform); const mpvConfigDir = platform === 'darwin' - ? path.join(homeDir, 'Library', 'Application Support', 'mpv') + ? platformPath.join(homeDir, 'Library', 'Application Support', 'mpv') : platform === 'linux' - ? path.join(xdgConfigHome?.trim() || path.join(homeDir, '.config'), 'mpv') - : path.join(homeDir, 'AppData', 'Roaming', 'mpv'); + ? platformPath.join(xdgConfigHome?.trim() || platformPath.join(homeDir, '.config'), 'mpv') + : platformPath.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'), - pluginDir: path.join(mpvConfigDir, 'scripts', 'subminer'), - pluginConfigPath: path.join(mpvConfigDir, 'script-opts', 'subminer.conf'), + scriptsDir: platformPath.join(mpvConfigDir, 'scripts'), + scriptOptsDir: platformPath.join(mpvConfigDir, 'script-opts'), + pluginEntrypointPath: platformPath.join(mpvConfigDir, 'scripts', 'subminer', 'main.lua'), + pluginDir: platformPath.join(mpvConfigDir, 'scripts', 'subminer'), + pluginConfigPath: platformPath.join(mpvConfigDir, 'script-opts', 'subminer.conf'), }; } diff --git a/src/subsync/utils.ts b/src/subsync/utils.ts index 74635ca..5dc4ba5 100644 --- a/src/subsync/utils.ts +++ b/src/subsync/utils.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as childProcess from 'child_process'; +import * as path from 'path'; import { DEFAULT_CONFIG } from '../config'; import { SubsyncConfig, SubsyncMode } from '../types'; @@ -45,6 +46,42 @@ export interface CommandResult { error?: string; } +function resolveCommandInvocation( + executable: string, + args: string[], +): { command: string; args: string[] } { + if (process.platform !== 'win32') { + return { command: executable, args }; + } + + const normalizeBashArg = (value: string): string => { + const normalized = value.replace(/\\/g, '/'); + const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/); + if (!driveMatch) { + return normalized; + } + + const [, driveLetter, remainder] = driveMatch; + return `/mnt/${driveLetter!.toLowerCase()}/${remainder}`; + }; + const extension = path.extname(executable).toLowerCase(); + if (extension === '.ps1') { + return { + command: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', executable, ...args], + }; + } + + if (extension === '.sh') { + return { + command: 'bash', + args: [normalizeBashArg(executable), ...args.map(normalizeBashArg)], + }; + } + + return { command: executable, args }; +} + export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncResolvedConfig { const resolvePath = (value: string | undefined, fallback: string): string => { const trimmed = value?.trim(); @@ -108,7 +145,8 @@ export function runCommand( timeoutMs = 120000, ): Promise { return new Promise((resolve) => { - const child = childProcess.spawn(executable, args, { + const invocation = resolveCommandInvocation(executable, args); + const child = childProcess.spawn(invocation.command, invocation.args, { stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; diff --git a/src/window-trackers/base-tracker.ts b/src/window-trackers/base-tracker.ts index 5778a80..d19172c 100644 --- a/src/window-trackers/base-tracker.ts +++ b/src/window-trackers/base-tracker.ts @@ -21,17 +21,31 @@ 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; + private onWindowFocusChangeCallback: ((focused: boolean) => void) | null = null; + + public get onWindowFocusChange(): ((focused: boolean) => void) | null { + return this.onWindowFocusChangeCallback; + } + + public set onWindowFocusChange(callback: ((focused: boolean) => void) | null) { + this.onWindowFocusChangeCallback = callback; + } + + public get onTargetWindowFocusChange(): ((focused: boolean) => void) | null { + return this.onWindowFocusChange; + } + + public set onTargetWindowFocusChange(callback: ((focused: boolean) => void) | null) { + this.onWindowFocusChange = callback; + } abstract start(): void; abstract stop(): void; @@ -44,23 +58,28 @@ export abstract class BaseWindowTracker { return this.windowFound; } - isFocused(): boolean { - return this.focusKnown ? this.windowFocused : this.windowFound; + isTargetWindowFocused(): boolean { + return this.targetWindowFocused; + } + + protected updateTargetWindowFocused(focused: boolean): void { + if (this.targetWindowFocused === focused) { + return; + } + + this.targetWindowFocused = focused; + this.onWindowFocusChangeCallback?.(focused); } protected updateFocus(focused: boolean): void { - const changed = !this.focusKnown || this.windowFocused !== focused; - this.focusKnown = true; - this.windowFocused = focused; - if (changed) { - this.onWindowFocusChange?.(focused); - } + this.updateTargetWindowFocused(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 +94,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(); } diff --git a/src/window-trackers/index.ts b/src/window-trackers/index.ts index fb635e5..9406419 100644 --- a/src/window-trackers/index.ts +++ b/src/window-trackers/index.ts @@ -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; 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, }; diff --git a/src/window-trackers/windows-helper.test.ts b/src/window-trackers/windows-helper.test.ts new file mode 100644 index 0000000..76712c2 --- /dev/null +++ b/src/window-trackers/windows-helper.test.ts @@ -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', + }); +}); diff --git a/src/window-trackers/windows-helper.ts b/src/window-trackers/windows-helper.ts new file mode 100644 index 0000000..24975ed --- /dev/null +++ b/src/window-trackers/windows-helper.ts @@ -0,0 +1,284 @@ +/* + 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 . +*/ + +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; +}; + +const windowsPath = path.win32; + +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 + >, +): string | null { + if (!sourcePath.includes('.asar')) { + return sourcePath; + } + + const fileName = + kind === 'native' ? 'get-mpv-window-windows.exe' : 'get-mpv-window-windows.ps1'; + const targetDir = windowsPath.join(os.tmpdir(), 'subminer', 'helpers'); + const targetPath = windowsPath.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: windowsPath.join(resourcesPath, 'scripts', `${scriptFileBase}.exe`), + kind: 'native', + }); + candidates.push({ + path: windowsPath.join(resourcesPath, 'scripts', `${scriptFileBase}.ps1`), + kind: 'powershell', + }); + } + + candidates.push({ + path: windowsPath.join(dirname, '..', 'scripts', `${scriptFileBase}.exe`), + kind: 'native', + }); + candidates.push({ + path: windowsPath.join(dirname, '..', 'scripts', `${scriptFileBase}.ps1`), + kind: 'powershell', + }); + candidates.push({ + path: windowsPath.join(dirname, '..', '..', 'scripts', `${scriptFileBase}.exe`), + kind: 'native', + }); + candidates.push({ + path: windowsPath.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; +} diff --git a/src/window-trackers/windows-tracker.test.ts b/src/window-trackers/windows-tracker.test.ts new file mode 100644 index 0000000..2b643bd --- /dev/null +++ b/src/window-trackers/windows-tracker.test.ts @@ -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((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 = []; + 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); +}); diff --git a/src/window-trackers/windows-tracker.ts b/src/window-trackers/windows-tracker.ts new file mode 100644 index 0000000..f291571 --- /dev/null +++ b/src/window-trackers/windows-tracker.ts @@ -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 . +*/ + +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; +}; + +function runHelperWithExecFile( + spec: WindowsTrackerHelperLaunchSpec, + mode: 'geometry', + targetMpvSocketPath: string | null, +): Promise { + 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 | 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; + 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 { + 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; + }); + } +} diff --git a/vendor/subminer-yomitan b/vendor/subminer-yomitan index 66cb7a0..9863d86 160000 --- a/vendor/subminer-yomitan +++ b/vendor/subminer-yomitan @@ -1 +1 @@ -Subproject commit 66cb7a06f1f6a097d5ff5e704617ff755817711a +Subproject commit 9863d865e14fc1c9df4f1a7be3a541d557873d2e diff --git a/vendor/texthooker-ui b/vendor/texthooker-ui index e8c7ae1..534cd66 160000 --- a/vendor/texthooker-ui +++ b/vendor/texthooker-ui @@ -1 +1 @@ -Subproject commit e8c7ae1122e6d0635770225f2f853d8b00ed488c +Subproject commit 534cd66b6ea3d52875acbd871d1013ba49034b72