Prepare Windows release and signing process (#16)

This commit is contained in:
2026-03-08 19:51:30 -07:00
committed by GitHub
parent 34d2dce8dc
commit c799a8de3c
113 changed files with 5042 additions and 386 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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"

View File

@@ -5,7 +5,7 @@
<br /><br />
[![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)
</div>
@@ -20,17 +20,24 @@
<br />
Initial packaged Windows support is now available alongside the existing Linux and macOS builds.
## What it does
SubMiner is an Electron overlay that sits on top of mpv. It turns your video player into a full sentence-mining workstation:
- **Dictionary lookups** — Yomitan popups on subtitles with hover or full keyboard-driven navigation; hover-aware auto-pause keeps playback in sync
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and AI-powered translation
- **Reading annotations** — N+1 targeting, frequency highlighting, and JLPT underlining while you watch
- **Subtitle tools** — Jimaku downloads, alass/ffsubsync sync, and YouTube subtitle generation via manual-track reuse plus whisper.cpp fallback with optional AI cleanup
- **Texthooker** — Built-in texthooker page and annotated websocket API for external clients
- **Immersion tracking** — SQLite-powered stats on watch time and mining activity
- **Integrations** — Jellyfin remote playback, AniList episode progress, and AnkiConnect auto-enrichment
- **Hover to look up** — Yomitan dictionary popups directly on subtitles
- **Keyboard-driven lookup mode** — Navigate token-by-token, keep lookup open across tokens, and control popup scrolling/audio/mining without leaving the overlay
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
- **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately
- **Reading annotations** — Combines N+1 targeting, frequency-dictionary highlighting, and JLPT underlining while you read
- **Hover-aware playback** — By default, hovering subtitle text pauses mpv and resumes on mouse leave (`subtitleStyle.autoPauseVideoOnHover`)
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity
- **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup
- **Annotated websocket API** — Dedicated annotation feed can serve bundled texthooker or external clients with rendered `sentence` HTML plus structured `tokens`
- **Jellyfin integration** — Remote playback setup, cast device mode, and direct playback launch
- **AniList progress** — Track episode completion and push watching progress automatically
## Quick start
@@ -49,14 +56,21 @@ chmod +x ~/.local/bin/subminer
> [!NOTE]
> The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. Make sure `bun` is on your `PATH`.
**From source** or **macOS** — initialize submodules first (`git submodule update --init --recursive`). Bundled Yomitan is built natively with Bun from the `vendor/subminer-yomitan` submodule into `build/yomitan` during `bun run build`, so Bun is the only JS runtime/package manager required for source builds. Full install guide: [docs.subminer.moe/installation#from-source](https://docs.subminer.moe/installation#from-source).
**macOS (DMG/ZIP):** download the latest packaged build from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
**Windows (Installer/ZIP):** download the latest `SubMiner-<version>.exe` installer or portable `.zip` from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Keep `mpv` installed and available on `PATH`.
**From source** — initialize submodules first (`git submodule update --init --recursive`). 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

View File

@@ -0,0 +1,59 @@
---
id: TASK-117
title: Prepare initial Windows release docs and version bump
status: Done
assignee:
- codex
created_date: '2026-03-08 15:17'
updated_date: '2026-03-08 15:17'
labels:
- release
- docs
- windows
dependencies: []
references:
- package.json
- README.md
- ../subminer-docs/installation.md
- ../subminer-docs/usage.md
- ../subminer-docs/changelog.md
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Prepare the initial packaged Windows release by bumping the app version and refreshing the release-facing README/backlog/docs surfaces so install and direct-command guidance no longer reads Linux-only.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 App version is bumped for the Windows release cut
- [x] #2 README and sibling docs describe Windows packaged installation alongside Linux/macOS guidance
- [x] #3 Backlog records the release-doc/version update with the modified references
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Bump the package version for the release cut.
2. Update the root README install/start guidance to mention Windows packaged builds.
3. Patch the sibling docs repo installation, usage, and changelog pages for the Windows release.
4. Record the work in Backlog and run targeted verification on the touched surfaces.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
The public README still advertised Linux/macOS only, while the sibling docs had Windows-specific runtime notes but no actual Windows install section and several direct-command examples still assumed `SubMiner.AppImage`.
Bumped `package.json` to `0.5.0`, expanded the README platform/install copy to include Windows, added a Windows install section to `../subminer-docs/installation.md`, clarified in `../subminer-docs/usage.md` that direct packaged-app examples use `SubMiner.exe` on Windows, and added a `v0.5.0` changelog entry covering the initial Windows release plus the latest overlay behavior polish.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Prepared the initial Windows release documentation pass and version bump. `package.json` now reports `0.5.0`. The root `README.md` now advertises Linux, macOS, and Windows support, includes Windows packaged-install guidance, and clarifies first-launch behavior across platforms. In the sibling docs repo, `installation.md` now includes a dedicated Windows install section, `usage.md` explains that direct packaged-app examples use `SubMiner.exe` on Windows, and `changelog.md` now includes the `v0.5.0` release notes for the initial Windows build and recent overlay behavior changes.
Verification: targeted `bun run tsc --noEmit -p tsconfig.typecheck.json` in the app repo and `bun run docs:build` in `../subminer-docs`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,64 @@
---
id: TASK-118
title: Add Windows release build and SignPath signing
status: Done
assignee:
- codex
created_date: '2026-03-08 15:17'
updated_date: '2026-03-08 15:17'
labels:
- release
- windows
- signing
dependencies: []
references:
- .github/workflows/release.yml
- build/installer.nsh
- build/signpath-windows-artifact-config.xml
- package.json
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Extend the tag-driven release workflow so Windows artifacts are built on GitHub-hosted runners and submitted to SignPath for free open-source Authenticode signing, while preserving the existing macOS notarization path.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Release workflow builds Windows installer and ZIP artifacts on `windows-latest`
- [x] #2 Workflow submits unsigned Windows artifacts to SignPath and uploads the signed outputs for release publication
- [x] #3 Repository includes a checked-in SignPath artifact-configuration source of truth for the Windows release files
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect the existing release workflow and current Windows packaging configuration.
2. Add a Windows release job that builds unsigned artifacts, uploads them as a workflow artifact, and submits them to SignPath.
3. Update the release aggregation job to publish signed Windows assets and mention Windows install steps in the generated release notes.
4. Check in the Windows SignPath artifact configuration XML used to define what gets signed.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
The repository already had Windows packaging configuration (`build:win`, NSIS include script, Windows helper asset packaging), but the release workflow still built Linux and macOS only.
Added a `build-windows` job to `.github/workflows/release.yml` that runs on `windows-latest`, validates required SignPath secrets, builds unsigned Windows artifacts, uploads them with `actions/upload-artifact@v4`, and then calls the official `signpath/github-action-submit-signing-request@v2` action to retrieve signed outputs.
Checked in `build/signpath-windows-artifact-config.xml` as the source-of-truth artifact configuration for SignPath. It signs the top-level NSIS installer EXE and deep-signs `.exe` and `.dll` files inside the portable ZIP artifact.
Updated the release aggregation job to download the signed Windows artifacts and added a Windows install section to the generated GitHub release body.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Windows release publishing is now wired into the tag-driven workflow. `.github/workflows/release.yml` builds Windows artifacts on `windows-latest`, submits them to SignPath using the official GitHub action, and publishes the signed `.exe` and `.zip` outputs alongside the Linux and macOS artifacts. The workflow now requests the additional `actions: read` permission required by the SignPath GitHub integration, and the generated release notes now include Windows installation steps.
The checked-in `build/signpath-windows-artifact-config.xml` file defines the SignPath artifact structure expected by the workflow artifact ZIP: sign the top-level `SubMiner-*.exe` installer and deep-sign `.exe` and `.dll` files inside `SubMiner-*.zip`.
Verification: workflow/static changes were checked with `git diff --check` on the touched files. Actual signing requires configured SignPath secrets and a matching artifact configuration in your SignPath project.
<!-- SECTION:FINAL_SUMMARY:END -->

153
build/installer.nsh Normal file
View File

@@ -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

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="SubMiner-*.exe" max-matches="unbounded">
<authenticode-sign />
</pe-file>
<zip-file path="SubMiner-*.zip" max-matches="unbounded">
<directory path="*">
<pe-file path="*.exe" max-matches="unbounded">
<authenticode-sign />
</pe-file>
<pe-file path="*.dll" max-matches="unbounded">
<authenticode-sign />
</pe-file>
<pe-file path="*.node" max-matches="unbounded">
<authenticode-sign />
</pe-file>
</directory>
</zip-file>
</zip-file>
</artifact-configuration>

View File

@@ -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.

View File

@@ -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');
});

View File

@@ -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,

View File

@@ -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'),
]),
);
}

View File

@@ -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,

24
launcher/log.test.ts Normal file
View File

@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { getDefaultMpvLogFile } from './types.js';
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
const resolved = getDefaultMpvLogFile({
platform: 'win32',
homeDir: 'C:\\Users\\tester',
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
});
assert.equal(
path.normalize(resolved),
path.normalize(
path.join(
'C:\\Users\\tester\\AppData\\Roaming',
'SubMiner',
'logs',
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
),
),
);
});

View File

@@ -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', () => {

View File

@@ -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,
});

View File

@@ -7,22 +7,26 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
const sequence: Array<SetupState | null> = [
null,
{
version: 1,
version: 2,
status: 'in_progress',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
},
{
version: 1,
version: 2,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'skipped',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'skipped',
},
];
@@ -50,23 +54,27 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
if (reads === 1) return null;
if (reads === 2) {
return {
version: 1,
version: 2,
status: 'in_progress',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
};
}
return {
version: 1,
version: 2,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'user',
lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'installed',
};
},
launchSetupApp: () => {
@@ -88,13 +96,15 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
const result = await ensureLauncherSetupReady({
readSetupState: () => ({
version: 1,
version: 2,
status: 'cancelled',
completedAt: null,
completionSource: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
}),
launchSetupApp: () => undefined,
sleep: async () => undefined,

View File

@@ -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 = [

View File

@@ -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}`}`),
);

View File

@@ -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"
}
]
}

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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,

163
scripts/build-yomitan.mjs Normal file
View File

@@ -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();

View File

@@ -0,0 +1,101 @@
import fs from 'node:fs';
import path from 'node:path';
function normalizeCandidate(candidate) {
if (typeof candidate !== 'string') return '';
const trimmed = candidate.trim();
return trimmed.length > 0 ? trimmed : '';
}
function fileExists(candidate) {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}
function unique(values) {
return Array.from(new Set(values.filter((value) => value.length > 0)));
}
function findWindowsBinary(repoRoot) {
const homeDir = process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || '';
const appDataDir = process.env.APPDATA?.trim() || '';
const derivedLocalAppData =
appDataDir && /[\\/]Roaming$/i.test(appDataDir)
? appDataDir.replace(/[\\/]Roaming$/i, `${path.sep}Local`)
: '';
const localAppData =
process.env.LOCALAPPDATA?.trim() ||
derivedLocalAppData ||
(homeDir ? path.join(homeDir, 'AppData', 'Local') : '');
const programFiles = process.env.ProgramFiles?.trim() || 'C:\\Program Files';
const programFilesX86 = process.env['ProgramFiles(x86)']?.trim() || 'C:\\Program Files (x86)';
const candidates = unique([
normalizeCandidate(process.env.SUBMINER_BINARY_PATH),
normalizeCandidate(process.env.SUBMINER_APPIMAGE_PATH),
localAppData ? path.join(localAppData, 'Programs', 'SubMiner', 'SubMiner.exe') : '',
path.join(programFiles, 'SubMiner', 'SubMiner.exe'),
path.join(programFilesX86, 'SubMiner', 'SubMiner.exe'),
'C:\\SubMiner\\SubMiner.exe',
path.join(repoRoot, 'release', 'win-unpacked', 'SubMiner.exe'),
path.join(repoRoot, 'release', 'SubMiner', 'SubMiner.exe'),
path.join(repoRoot, 'release', 'SubMiner.exe'),
]);
return candidates.find((candidate) => fileExists(candidate)) || '';
}
function rewriteBinaryPath(configPath, binaryPath) {
const content = fs.readFileSync(configPath, 'utf8');
const normalizedPath = binaryPath.replace(/\r?\n/g, ' ').trim();
const updated = content.replace(/^binary_path=.*$/m, `binary_path=${normalizedPath}`);
if (updated !== content) {
fs.writeFileSync(configPath, updated, 'utf8');
}
}
function rewriteSocketPath(configPath, socketPath) {
const content = fs.readFileSync(configPath, 'utf8');
const normalizedPath = socketPath.replace(/\r?\n/g, ' ').trim();
const updated = content.replace(/^socket_path=.*$/m, `socket_path=${normalizedPath}`);
if (updated !== content) {
fs.writeFileSync(configPath, updated, 'utf8');
}
}
const [, , configPathArg, repoRootArg, platformArg] = process.argv;
const configPath = normalizeCandidate(configPathArg);
const repoRoot = normalizeCandidate(repoRootArg) || process.cwd();
const platform = normalizeCandidate(platformArg) || process.platform;
if (!configPath) {
console.error('[ERROR] Missing plugin config path');
process.exit(1);
}
if (!fileExists(configPath)) {
console.error(`[ERROR] Plugin config not found: ${configPath}`);
process.exit(1);
}
if (platform !== 'win32') {
console.log('[INFO] Skipping binary_path rewrite for non-Windows platform');
process.exit(0);
}
const windowsSocketPath = '\\\\.\\pipe\\subminer-socket';
rewriteSocketPath(configPath, windowsSocketPath);
const binaryPath = findWindowsBinary(repoRoot);
if (!binaryPath) {
console.warn(
`[WARN] Configured plugin socket_path=${windowsSocketPath} but could not detect SubMiner.exe; set binary_path manually or provide SUBMINER_BINARY_PATH`,
);
process.exit(0);
}
rewriteBinaryPath(configPath, binaryPath);
console.log(`[INFO] Configured plugin socket_path=${windowsSocketPath} binary_path=${binaryPath}`);

View File

@@ -0,0 +1,175 @@
param(
[ValidateSet('geometry')]
[string]$Mode = 'geometry',
[string]$SocketPath
)
$ErrorActionPreference = 'Stop'
try {
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class SubMinerWindowsHelper {
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)]
public struct RECT {
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[DllImport("user32.dll")]
public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern bool IsIconic(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", SetLastError = true)]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("dwmapi.dll")]
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
}
"@
$DWMWA_EXTENDED_FRAME_BOUNDS = 9
function Get-WindowBounds {
param([IntPtr]$hWnd)
$rect = New-Object SubMinerWindowsHelper+RECT
$size = [System.Runtime.InteropServices.Marshal]::SizeOf($rect)
$dwmResult = [SubMinerWindowsHelper]::DwmGetWindowAttribute(
$hWnd,
$DWMWA_EXTENDED_FRAME_BOUNDS,
[ref]$rect,
$size
)
if ($dwmResult -ne 0) {
if (-not [SubMinerWindowsHelper]::GetWindowRect($hWnd, [ref]$rect)) {
return $null
}
}
$width = $rect.Right - $rect.Left
$height = $rect.Bottom - $rect.Top
if ($width -le 0 -or $height -le 0) {
return $null
}
return [PSCustomObject]@{
X = $rect.Left
Y = $rect.Top
Width = $width
Height = $height
Area = $width * $height
}
}
$commandLineByPid = @{}
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
foreach ($process in Get-CimInstance Win32_Process) {
$commandLineByPid[[uint32]$process.ProcessId] = $process.CommandLine
}
}
$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
}

View File

@@ -0,0 +1,84 @@
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
const windowsHelperSourcePath = path.join(scriptDir, 'get-mpv-window-windows.ps1');
const windowsHelperOutputPath = path.join(scriptsOutputDir, 'get-mpv-window-windows.ps1');
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
const macosHelperSourceCopyPath = path.join(scriptsOutputDir, 'get-mpv-window-macos.swift');
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function copyFile(sourcePath, outputPath) {
ensureDir(path.dirname(outputPath));
fs.copyFileSync(sourcePath, outputPath);
}
function copyRendererAssets() {
copyFile(path.join(rendererSourceDir, 'index.html'), path.join(rendererOutputDir, 'index.html'));
copyFile(path.join(rendererSourceDir, 'style.css'), path.join(rendererOutputDir, 'style.css'));
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(rendererOutputDir, 'fonts'), {
recursive: true,
force: true,
});
process.stdout.write(`Staged renderer assets in ${rendererOutputDir}\n`);
}
function stageWindowsHelper() {
copyFile(windowsHelperSourcePath, windowsHelperOutputPath);
process.stdout.write(`Staged Windows helper: ${windowsHelperOutputPath}\n`);
}
function fallbackToMacosSource() {
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
}
function shouldSkipMacosHelperBuild() {
return process.env.SUBMINER_SKIP_MACOS_HELPER_BUILD === '1';
}
function buildMacosHelper() {
if (shouldSkipMacosHelperBuild()) {
process.stdout.write('Skipping macOS helper build (SUBMINER_SKIP_MACOS_HELPER_BUILD=1)\n');
fallbackToMacosSource();
return;
}
if (process.platform !== 'darwin') {
process.stdout.write('Skipping macOS helper build (not on macOS)\n');
fallbackToMacosSource();
return;
}
try {
execFileSync('swiftc', ['-O', macosHelperSourcePath, '-o', macosHelperBinaryPath], {
stdio: 'inherit',
});
fs.chmodSync(macosHelperBinaryPath, 0o755);
process.stdout.write(`Built macOS helper: ${macosHelperBinaryPath}\n`);
} catch (error) {
process.stdout.write('Failed to compile macOS helper; using source fallback.\n');
fallbackToMacosSource();
if (error instanceof Error) {
process.stderr.write(`${error.message}\n`);
}
}
}
function main() {
copyRendererAssets();
stageWindowsHelper();
buildMacosHelper();
}
main();

View File

@@ -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[@]}"

View File

@@ -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': {

View File

@@ -0,0 +1,223 @@
local function assert_equal(actual, expected, message)
if actual == expected then
return
end
error((message or "assert_equal failed") .. "\nexpected: " .. tostring(expected) .. "\nactual: " .. tostring(actual))
end
local function assert_true(condition, message)
if condition then
return
end
error(message or "assert_true failed")
end
local function with_env(env, callback)
local original_getenv = os.getenv
os.getenv = function(name)
local value = env[name]
if value ~= nil then
return value
end
return original_getenv(name)
end
local ok, result = pcall(callback)
os.getenv = original_getenv
if not ok then
error(result)
end
return result
end
local function create_binary_module(config)
local binary_module = dofile("plugin/subminer/binary.lua")
local entries = config.entries or {}
local binary = binary_module.create({
mp = config.mp,
utils = {
file_info = function(path)
local entry = entries[path]
if entry == "file" then
return { is_dir = false }
end
if entry == "dir" then
return { is_dir = true }
end
return nil
end,
join_path = function(...)
return table.concat({ ... }, "\\")
end,
},
opts = {
binary_path = config.binary_path or "",
},
state = {},
environment = {
is_windows = function()
return config.is_windows == true
end,
},
log = {
subminer_log = function() end,
},
})
return binary
end
do
local binary = create_binary_module({
is_windows = true,
binary_path = "C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner",
entries = {
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
},
})
assert_equal(
binary.find_binary(),
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
"windows resolver should append .exe for configured binary_path"
)
end
do
local binary = create_binary_module({
is_windows = true,
mp = {
command_native = function(command)
local args = command.args or {}
if args[1] == "powershell.exe" then
return {
status = 0,
stdout = "C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe\n",
stderr = "",
}
end
return {
status = 1,
stdout = "",
stderr = "unexpected command",
}
end,
},
entries = {
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
},
})
assert_equal(
binary.find_binary(),
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
"windows resolver should recover binary from running SubMiner process"
)
end
do
local binary = create_binary_module({
is_windows = true,
binary_path = "C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner",
entries = {
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner"] = "dir",
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
},
})
assert_equal(
binary.find_binary(),
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
"windows resolver should accept install directory binary_path"
)
end
do
local resolved = with_env({
LOCALAPPDATA = "C:\\Users\\tester\\AppData\\Local",
HOME = "",
USERPROFILE = "C:\\Users\\tester",
ProgramFiles = "C:\\Program Files",
["ProgramFiles(x86)"] = "C:\\Program Files (x86)",
}, function()
local binary = create_binary_module({
is_windows = true,
entries = {
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
},
})
return binary.find_binary()
end)
assert_equal(
resolved,
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
"windows auto-detection should probe LOCALAPPDATA install path"
)
end
do
local resolved = with_env({
APPDATA = "C:\\Users\\tester\\AppData\\Roaming",
LOCALAPPDATA = "",
HOME = "",
USERPROFILE = "C:\\Users\\tester",
ProgramFiles = "C:\\Program Files",
["ProgramFiles(x86)"] = "C:\\Program Files (x86)",
}, function()
local binary = create_binary_module({
is_windows = true,
entries = {
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
},
})
return binary.find_binary()
end)
assert_equal(
resolved,
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
"windows auto-detection should derive Local install path from APPDATA"
)
end
do
local resolved = with_env({
SUBMINER_BINARY_PATH = "C:\\Portable\\SubMiner\\SubMiner",
}, function()
local binary = create_binary_module({
is_windows = true,
entries = {
["C:\\Portable\\SubMiner\\SubMiner.exe"] = "file",
},
})
return binary.find_binary()
end)
assert_equal(
resolved,
"C:\\Portable\\SubMiner\\SubMiner.exe",
"windows env override should resolve .exe suffix"
)
end
do
local binary = create_binary_module({
is_windows = true,
binary_path = "C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner",
entries = {
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner"] = "dir",
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
},
})
assert_true(binary.ensure_binary_available() == true, "ensure_binary_available should cache discovered windows binary")
assert_equal(
binary.find_binary(),
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
"ensure_binary_available should not break follow-up lookup"
)
end
print("plugin windows binary resolver tests: OK")

View File

@@ -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")

View File

@@ -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);

View File

@@ -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;

View File

@@ -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/);

View File

@@ -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}

View File

@@ -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'));
});

View File

@@ -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]!);
}

View File

@@ -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('{');

View File

@@ -7,6 +7,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,

View File

@@ -7,6 +7,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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()) {

View File

@@ -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;

View File

@@ -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);
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();
}

View File

@@ -1,11 +1,9 @@
import electron from 'electron';
import type { BrowserWindow } from 'electron';
import { BrowserWindow } from 'electron';
import * as path from 'path';
import { WindowGeometry } from '../../types';
import { createLogger } from '../../logger';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
const { BrowserWindow: ElectronBrowserWindow } = electron;
const logger = createLogger('main:overlay-window');
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
@@ -20,7 +18,7 @@ function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind)
.loadFile(htmlPath, {
query: { layer },
})
.catch((err: unknown) => {
.catch((err) => {
logger.error('Failed to load HTML file:', err);
});
}
@@ -65,6 +63,11 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
window.setFullScreenable(false);
return;
}
if (process.platform === 'win32') {
window.setAlwaysOnTop(true, 'screen-saver', 1);
window.moveTop();
return;
}
window.setAlwaysOnTop(true);
}
@@ -92,7 +95,8 @@ export function createOverlayWindow(
onWindowClosed: (kind: OverlayWindowKind) => void;
},
): BrowserWindow {
const window = new ElectronBrowserWindow({
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const window = new BrowserWindow({
show: false,
width: 800,
height: 600,
@@ -106,6 +110,7 @@ export function createOverlayWindow(
hasShadow: false,
focusable: true,
acceptFirstMouse: true,
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
webPreferences: {
preload: path.join(__dirname, '..', '..', 'preload.js'),
contextIsolation: true,
@@ -162,6 +167,9 @@ export function createOverlayWindow(
window.on('blur', () => {
if (!window.isDestroyed()) {
options.ensureOverlayWindowLevel(window);
if (kind === 'visible' && window.isVisible()) {
window.moveTop();
}
}
});

View File

@@ -7,6 +7,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,

View File

@@ -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<Array<string | number>> = [];
@@ -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'), '');
});

View File

@@ -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'),
]);
});

42
src/logger.test.ts Normal file
View File

@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { resolveDefaultLogFilePath } from './logger';
test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
const resolved = resolveDefaultLogFilePath({
platform: 'win32',
homeDir: 'C:\\Users\\tester',
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
});
assert.equal(
path.normalize(resolved),
path.normalize(
path.join(
'C:\\Users\\tester\\AppData\\Roaming',
'SubMiner',
'logs',
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
),
),
);
});
test('resolveDefaultLogFilePath uses .config on linux', () => {
const resolved = resolveDefaultLogFilePath({
platform: 'linux',
homeDir: '/home/tester',
});
assert.equal(
resolved,
path.join(
'/home/tester',
'.config',
'SubMiner',
'logs',
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
),
);
});

View File

@@ -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 {

View File

@@ -2,14 +2,21 @@ 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', () => {
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',
@@ -28,6 +35,20 @@ test('normalizeStartupArgv defaults no-arg startup to --start --background', ()
'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',

View File

@@ -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';

View File

@@ -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');
}

View File

@@ -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<typeof app.on>[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<typeof setTimeout> | null = null;
function requestAppQuit(): void {
if (!forceQuitTimer) {
forceQuitTimer = setTimeout(() => {
logger.warn('App quit timed out; forcing process exit.');
app.exit(0);
}, 2000);
}
app.quit();
}
process.on('SIGINT', () => {
requestAppQuit();
});
process.on('SIGTERM', () => {
app.quit();
requestAppQuit();
});
const overlayManager = createOverlayManager();
@@ -623,7 +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<typeof createStartupBootstrapRuntimeDeps>
>({
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<typeof createTokenizerDepsRuntime>,
@@ -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<Extension | null> {
let lastSyncedYomitanAnkiServer: string | null = null;
function getPreferredYomitanAnkiServerUrl(): string {
const config = getResolvedConfig().ankiConnect;
if (config.proxy?.enabled) {
const host = config.proxy.host || '127.0.0.1';
const port = config.proxy.port || 8766;
return `http://${host}:${port}`;
}
return config.url;
return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect);
}
function getYomitanParserRuntimeDeps() {
@@ -2894,7 +3003,7 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
},
},
{
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,

View File

@@ -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'],
]);
});

View File

@@ -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();
}

View File

@@ -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) =>

View File

@@ -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(),
});

View File

@@ -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'));

View File

@@ -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();
};

View File

@@ -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: () => {},
});

View File

@@ -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(),
});

View File

@@ -35,6 +35,10 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
clearAnilistSetupWindow: () => {},
getJellyfinSetupWindow: () => null,
clearJellyfinSetupWindow: () => {},
getFirstRunSetupWindow: () => null,
clearFirstRunSetupWindow: () => {},
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {},
stopDiscordPresenceService: () => {},
},

View File

@@ -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({

View File

@@ -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 () => [
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,
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'),
...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 () => [
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,
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'),
...platformRoots,
deps.cwd,
];
};
}
export function createBuildJlptDictionaryRuntimeMainDepsHandler(deps: {

View File

@@ -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', () => {
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);
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: '/tmp/home',
xdgConfigHome: '/tmp/xdg',
dirname: '/tmp/dist/main/runtime',
appPath: '/tmp/app',
resourcesPath: '/tmp/resources',
homeDir,
dirname: path.join(root, 'dist', 'main', 'runtime'),
appPath: path.join(root, 'app'),
resourcesPath,
});
assert.equal(result.ok, false);
assert.equal(result.pluginInstallStatus, 'failed');
assert.match(result.message, /not supported/i);
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',
);
});
});

View File

@@ -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,

View File

@@ -21,6 +21,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return {
background: false,
start: false,
launchMpv: false,
launchMpvTargets: [],
stop: false,
toggle: false,
toggleVisibleOverlay: false,
@@ -169,3 +171,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']);
});
});

View File

@@ -7,16 +7,28 @@ import {
readSetupState,
writeSetupState,
type SetupPluginInstallStatus,
type SetupWindowsMpvShortcutInstallStatus,
type SetupState,
} from '../../shared/setup-state';
import type { CliArgs } from '../../cli/args';
export interface SetupWindowsMpvShortcutSnapshot {
supported: boolean;
startMenuEnabled: boolean;
desktopEnabled: boolean;
startMenuInstalled: boolean;
desktopInstalled: boolean;
status: 'installed' | 'optional' | 'skipped' | 'failed';
message: string | null;
}
export interface SetupStatusSnapshot {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
windowsMpvShortcuts: SetupWindowsMpvShortcutSnapshot;
message: string | null;
state: SetupState;
}
@@ -37,6 +49,10 @@ export interface FirstRunSetupService {
markSetupCompleted: () => Promise<SetupStatusSnapshot>;
skipPluginInstall: () => Promise<SetupStatusSnapshot>;
installMpvPlugin: () => Promise<SetupStatusSnapshot>;
configureWindowsMpvShortcuts: (preferences: {
startMenuEnabled: boolean;
desktopEnabled: boolean;
}) => Promise<SetupStatusSnapshot>;
isSetupCompleted: () => boolean;
}
@@ -44,6 +60,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
return Boolean(
args.toggle ||
args.toggleVisibleOverlay ||
args.launchMpv ||
args.settings ||
args.show ||
args.hide ||
@@ -95,15 +112,51 @@ function getPluginStatus(
return 'optional';
}
function getWindowsMpvShortcutStatus(
state: SetupState,
installed: { startMenuInstalled: boolean; desktopInstalled: boolean },
): SetupWindowsMpvShortcutSnapshot['status'] {
if (installed.startMenuInstalled || installed.desktopInstalled) return 'installed';
if (state.windowsMpvShortcutLastStatus === 'skipped') return 'skipped';
if (state.windowsMpvShortcutLastStatus === 'failed') return 'failed';
return 'optional';
}
function getEffectiveWindowsMpvShortcutPreferences(
state: SetupState,
installed: { startMenuInstalled: boolean; desktopInstalled: boolean },
): { startMenuEnabled: boolean; desktopEnabled: boolean } {
if (state.windowsMpvShortcutLastStatus === 'unknown') {
return {
startMenuEnabled: installed.startMenuInstalled,
desktopEnabled: installed.desktopInstalled,
};
}
return {
startMenuEnabled: state.windowsMpvShortcutPreferences.startMenuEnabled,
desktopEnabled: state.windowsMpvShortcutPreferences.desktopEnabled,
};
}
export function createFirstRunSetupService(deps: {
platform?: NodeJS.Platform;
configDir: string;
getYomitanDictionaryCount: () => Promise<number>;
detectPluginInstalled: () => boolean | Promise<boolean>;
installPlugin: () => Promise<PluginInstallResult>;
detectWindowsMpvShortcuts?: () =>
| { startMenuInstalled: boolean; desktopInstalled: boolean }
| Promise<{ startMenuInstalled: boolean; desktopInstalled: boolean }>;
applyWindowsMpvShortcuts?: (preferences: {
startMenuEnabled: boolean;
desktopEnabled: boolean;
}) => Promise<{ ok: boolean; status: SetupWindowsMpvShortcutInstallStatus; message: string }>;
onStateChanged?: (state: SetupState) => void;
}): FirstRunSetupService {
const setupStatePath = getSetupStatePath(deps.configDir);
const configFilePaths = getDefaultConfigFilePaths(deps.configDir);
const isWindows = (deps.platform ?? process.platform) === 'win32';
let completed = false;
const readState = (): SetupState => readSetupState(setupStatePath) ?? createDefaultSetupState();
@@ -117,6 +170,17 @@ export function createFirstRunSetupService(deps: {
const buildSnapshot = async (state: SetupState, message: string | null = null) => {
const dictionaryCount = await deps.getYomitanDictionaryCount();
const pluginInstalled = await deps.detectPluginInstalled();
const detectedWindowsMpvShortcuts = isWindows
? await deps.detectWindowsMpvShortcuts?.()
: undefined;
const installedWindowsMpvShortcuts = {
startMenuInstalled: detectedWindowsMpvShortcuts?.startMenuInstalled ?? false,
desktopInstalled: detectedWindowsMpvShortcuts?.desktopInstalled ?? false,
};
const effectiveWindowsMpvShortcutPreferences = getEffectiveWindowsMpvShortcutPreferences(
state,
installedWindowsMpvShortcuts,
);
const configReady =
fs.existsSync(configFilePaths.jsoncPath) || fs.existsSync(configFilePaths.jsonPath);
return {
@@ -125,6 +189,15 @@ export function createFirstRunSetupService(deps: {
canFinish: dictionaryCount >= 1,
pluginStatus: getPluginStatus(state, pluginInstalled),
pluginInstallPathSummary: state.pluginInstallPathSummary,
windowsMpvShortcuts: {
supported: isWindows,
startMenuEnabled: effectiveWindowsMpvShortcutPreferences.startMenuEnabled,
desktopEnabled: effectiveWindowsMpvShortcutPreferences.desktopEnabled,
startMenuInstalled: installedWindowsMpvShortcuts.startMenuInstalled,
desktopInstalled: installedWindowsMpvShortcuts.desktopInstalled,
status: getWindowsMpvShortcutStatus(state, installedWindowsMpvShortcuts),
message: null,
},
message,
state,
} satisfies SetupStatusSnapshot;
@@ -220,6 +293,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()),
};
}

View File

@@ -4,6 +4,7 @@ import {
buildFirstRunSetupHtml,
createHandleFirstRunSetupNavigationHandler,
createMaybeFocusExistingFirstRunSetupWindowHandler,
createOpenFirstRunSetupWindowHandler,
parseFirstRunSetupSubmissionUrl,
} from './first-run-setup-window';
@@ -14,6 +15,14 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
canFinish: false,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: 'Waiting for dictionaries',
});
@@ -31,6 +40,14 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
canFinish: true,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcuts: {
supported: true,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: true,
desktopInstalled: false,
status: 'installed',
},
message: null,
});
@@ -60,8 +77,8 @@ test('first-run setup navigation handler prevents default and dispatches action'
const calls: string[] = [];
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
handleAction: async (action) => {
calls.push(action);
handleAction: async (submission) => {
calls.push(submission.action);
},
logError: (message) => calls.push(message),
});
@@ -75,3 +92,71 @@ test('first-run setup navigation handler prevents default and dispatches action'
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
});
test('closing incomplete first-run setup quits app outside background mode', async () => {
const calls: string[] = [];
let closedHandler: (() => void) | undefined;
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: () => {},
},
loadURL: async () => undefined,
on: (event: 'closed', callback: () => void) => {
if (event === 'closed') {
closedHandler = callback;
}
},
isDestroyed: () => false,
close: () => calls.push('close-window'),
focus: () => {},
}) as never,
getSetupSnapshot: async () => ({
configReady: false,
dictionaryCount: 0,
canFinish: false,
pluginStatus: 'optional',
pluginInstallPathSummary: null,
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: null,
}),
buildSetupHtml: () => '<html></html>',
parseSubmissionUrl: () => null,
handleAction: async () => undefined,
markSetupInProgress: async () => undefined,
markSetupCancelled: async () => {
calls.push('cancelled');
},
isSetupCompleted: () => false,
shouldQuitWhenClosedIncomplete: () => true,
quitApp: () => {
calls.push('quit');
},
clearSetupWindow: () => {
calls.push('clear');
},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
if (typeof closedHandler !== 'function') {
throw new Error('expected closed handler');
}
closedHandler();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['set', 'cancelled', 'clear', 'quit']);
});

View File

@@ -16,17 +16,32 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
export type FirstRunSetupAction =
| 'install-plugin'
| 'configure-windows-mpv-shortcuts'
| 'open-yomitan-settings'
| 'refresh'
| 'skip-plugin'
| 'finish';
export interface FirstRunSetupSubmission {
action: FirstRunSetupAction;
startMenuEnabled?: boolean;
desktopEnabled?: boolean;
}
export interface FirstRunSetupHtmlModel {
configReady: boolean;
dictionaryCount: number;
canFinish: boolean;
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
pluginInstallPathSummary: string | null;
windowsMpvShortcuts: {
supported: boolean;
startMenuEnabled: boolean;
desktopEnabled: boolean;
startMenuInstalled: boolean;
desktopInstalled: boolean;
status: 'installed' | 'optional' | 'skipped' | 'failed';
};
message: string | null;
}
@@ -61,6 +76,43 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.pluginStatus === 'skipped'
? 'muted'
: 'warn';
const windowsShortcutLabel =
model.windowsMpvShortcuts.status === 'installed'
? 'Installed'
: model.windowsMpvShortcuts.status === 'skipped'
? 'Skipped'
: model.windowsMpvShortcuts.status === 'failed'
? 'Failed'
: 'Optional';
const windowsShortcutTone =
model.windowsMpvShortcuts.status === 'installed'
? 'ready'
: model.windowsMpvShortcuts.status === 'failed'
? 'danger'
: model.windowsMpvShortcuts.status === 'skipped'
? 'muted'
: 'warn';
const windowsShortcutCard = model.windowsMpvShortcuts.supported
? `
<div class="card block">
<div class="card-head">
<div>
<strong>Windows mpv launcher</strong>
<div class="meta">Create standalone \`SubMiner mpv\` shortcuts that run \`SubMiner.exe --launch-mpv\`.</div>
<div class="meta">Installed: Start Menu ${model.windowsMpvShortcuts.startMenuInstalled ? 'yes' : 'no'}, Desktop ${model.windowsMpvShortcuts.desktopInstalled ? 'yes' : 'no'}</div>
</div>
${renderStatusBadge(windowsShortcutLabel, windowsShortcutTone)}
</div>
<form
class="shortcut-form"
onsubmit="event.preventDefault(); const params = new URLSearchParams({ action: 'configure-windows-mpv-shortcuts', startMenu: document.getElementById('shortcut-start-menu').checked ? '1' : '0', desktop: document.getElementById('shortcut-desktop').checked ? '1' : '0' }); window.location.href = 'subminer://first-run-setup?' + params.toString();"
>
<label><input id="shortcut-start-menu" type="checkbox" ${model.windowsMpvShortcuts.startMenuEnabled ? 'checked' : ''} /> Create Start Menu shortcut</label>
<label><input id="shortcut-desktop" type="checkbox" ${model.windowsMpvShortcuts.desktopEnabled ? 'checked' : ''} /> Create Desktop shortcut</label>
<button type="submit">Apply mpv launcher shortcuts</button>
</form>
</div>`
: '';
return `<!doctype html>
<html>
@@ -109,10 +161,30 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
align-items: center;
gap: 12px;
}
.card.block {
display: block;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.meta {
color: var(--muted);
font-size: 12px;
}
.shortcut-form {
display: grid;
gap: 8px;
margin-top: 12px;
}
label {
color: var(--muted);
display: flex;
align-items: center;
gap: 8px;
}
.badge {
display: inline-flex;
align-items: center;
@@ -192,6 +264,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
model.dictionaryCount >= 1 ? 'ready' : 'warn',
)}
</div>
${windowsShortcutCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
@@ -208,7 +281,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
export function parseFirstRunSetupSubmissionUrl(
rawUrl: string,
): { action: FirstRunSetupAction } | null {
): FirstRunSetupSubmission | null {
if (!rawUrl.startsWith('subminer://first-run-setup')) {
return null;
}
@@ -216,6 +289,7 @@ export function parseFirstRunSetupSubmissionUrl(
const action = parsed.searchParams.get('action');
if (
action !== 'install-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
action !== 'open-yomitan-settings' &&
action !== 'refresh' &&
action !== 'skip-plugin' &&
@@ -223,6 +297,13 @@ export function parseFirstRunSetupSubmissionUrl(
) {
return null;
}
if (action === 'configure-windows-mpv-shortcuts') {
return {
action,
startMenuEnabled: parsed.searchParams.get('startMenu') === '1',
desktopEnabled: parsed.searchParams.get('desktop') === '1',
};
}
return { action };
}
@@ -238,15 +319,15 @@ export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
}
export function createHandleFirstRunSetupNavigationHandler(deps: {
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
handleAction: (action: FirstRunSetupAction) => Promise<unknown>;
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
handleAction: (submission: FirstRunSetupSubmission) => Promise<unknown>;
logError: (message: string, error: unknown) => void;
}) {
return (params: { url: string; preventDefault: () => void }): boolean => {
const submission = deps.parseSubmissionUrl(params.url);
if (!submission) return false;
params.preventDefault();
void deps.handleAction(submission.action).catch((error) => {
void deps.handleAction(submission).catch((error) => {
deps.logError('Failed handling first-run setup action', error);
});
return true;
@@ -260,11 +341,13 @@ export function createOpenFirstRunSetupWindowHandler<
createSetupWindow: () => TWindow;
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
handleAction: (action: FirstRunSetupAction) => Promise<{ closeWindow?: boolean } | void>;
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
handleAction: (submission: FirstRunSetupSubmission) => Promise<{ closeWindow?: boolean } | void>;
markSetupInProgress: () => Promise<unknown>;
markSetupCancelled: () => Promise<unknown>;
isSetupCompleted: () => boolean;
shouldQuitWhenClosedIncomplete: () => boolean;
quitApp: () => void;
clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void;
encodeURIComponent: (value: string) => string;
@@ -286,8 +369,8 @@ export function createOpenFirstRunSetupWindowHandler<
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
parseSubmissionUrl: deps.parseSubmissionUrl,
handleAction: async (action) => {
const result = await deps.handleAction(action);
handleAction: async (submission) => {
const result = await deps.handleAction(submission);
if (result?.closeWindow) {
if (!setupWindow.isDestroyed()) {
setupWindow.close();
@@ -313,12 +396,16 @@ export function createOpenFirstRunSetupWindowHandler<
});
setupWindow.on('closed', () => {
if (!deps.isSetupCompleted()) {
const setupCompleted = deps.isSetupCompleted();
if (!setupCompleted) {
void deps.markSetupCancelled().catch((error) => {
deps.logError('Failed marking first-run setup cancelled', error);
});
}
deps.clearSetupWindow();
if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) {
deps.quitApp();
}
});
void deps

View File

@@ -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);
});

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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'));

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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(),
});

View File

@@ -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',

View File

@@ -31,6 +31,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -42,6 +44,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
setVisibleOverlayVisible: (visible: boolean) => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
@@ -60,6 +63,10 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
deps.openFirstRunSetupWindow();
},
showFirstRunSetup: deps.showFirstRunSetup(),
openWindowsMpvLauncherSetup: () => {
deps.openFirstRunSetupWindow();
},
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
openYomitanSettings: () => {
deps.openYomitanSettings();
},

View File

@@ -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'),

View File

@@ -30,6 +30,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
openOverlay: () => void;
openFirstRunSetup: () => void;
showFirstRunSetup: boolean;
openWindowsMpvLauncherSetup: () => void;
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openRuntimeOptions: () => void;
openJellyfinSetup: () => void;
@@ -41,6 +43,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
setVisibleOverlayVisible: (visible: boolean) => void;
showFirstRunSetup: () => boolean;
openFirstRunSetupWindow: () => void;
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openRuntimeOptionsPalette: () => void;
openJellyfinSetupWindow: () => void;
@@ -54,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
showFirstRunSetup: deps.showFirstRunSetup,
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,

View File

@@ -29,6 +29,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
},
showFirstRunSetup: () => true,
openFirstRunSetupWindow: () => {},
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {},
openRuntimeOptionsPalette: () => {},
openJellyfinSetupWindow: () => {},

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildWindowsMpvLaunchArgs,
launchWindowsMpv,
resolveWindowsMpvPath,
type WindowsMpvLaunchDeps,
} from './windows-mpv-launch';
function createDeps(overrides: Partial<WindowsMpvLaunchDeps> = {}): WindowsMpvLaunchDeps {
return {
getEnv: () => undefined,
runWhere: () => ({ status: 1, stdout: '' }),
fileExists: () => false,
spawnDetached: () => undefined,
showError: () => undefined,
...overrides,
};
}
test('resolveWindowsMpvPath prefers SUBMINER_MPV_PATH', () => {
const resolved = resolveWindowsMpvPath(
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
}),
);
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
});
test('resolveWindowsMpvPath falls back to where.exe output', () => {
const resolved = resolveWindowsMpvPath(
createDeps({
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\nC:\\other\\mpv.exe\r\n' }),
fileExists: (candidate) => candidate === 'C:\\tools\\mpv.exe',
}),
);
assert.equal(resolved, 'C:\\tools\\mpv.exe');
});
test('buildWindowsMpvLaunchArgs keeps pseudo-gui profile and targets', () => {
assert.deepEqual(buildWindowsMpvLaunchArgs(['C:\\a.mkv', 'C:\\b.mkv']), [
'--player-operation-mode=pseudo-gui',
'--profile=subminer',
'C:\\a.mkv',
'C:\\b.mkv',
]);
});
test('launchWindowsMpv reports missing mpv path', () => {
const errors: string[] = [];
const result = launchWindowsMpv(
[],
createDeps({
showError: (_title, content) => errors.push(content),
}),
);
assert.equal(result.ok, false);
assert.equal(result.mpvPath, '');
assert.match(errors[0] ?? '', /Could not find mpv\.exe/i);
});
test('launchWindowsMpv spawns detached mpv with targets', () => {
const calls: string[] = [];
const result = launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: (command, args) => {
calls.push(command);
calls.push(args.join('|'));
},
}),
);
assert.equal(result.ok, true);
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
assert.deepEqual(calls, [
'C:\\mpv\\mpv.exe',
'--player-operation-mode=pseudo-gui|--profile=subminer|C:\\video.mkv',
]);
});
test('launchWindowsMpv reports spawn failures with path context', () => {
const errors: string[] = [];
const result = launchWindowsMpv(
[],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: () => {
throw new Error('spawn failed');
},
showError: (_title, content) => errors.push(content),
}),
);
assert.equal(result.ok, false);
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
assert.match(errors[0] ?? '', /Failed to launch mpv/i);
assert.match(errors[0] ?? '', /C:\\mpv\\mpv\.exe/i);
});

View File

@@ -0,0 +1,100 @@
import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
spawnDetached: (command: string, args: string[]) => void;
showError: (title: string, content: string) => void;
}
function normalizeCandidate(candidate: string | undefined): string {
return typeof candidate === 'string' ? candidate.trim() : '';
}
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
const envPath = normalizeCandidate(deps.getEnv('SUBMINER_MPV_PATH'));
if (envPath && deps.fileExists(envPath)) {
return envPath;
}
const whereResult = deps.runWhere();
if (whereResult.status === 0) {
const firstPath = whereResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0 && deps.fileExists(line));
if (firstPath) {
return firstPath;
}
}
return '';
}
export function buildWindowsMpvLaunchArgs(targets: string[]): string[] {
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...targets];
}
export function launchWindowsMpv(
targets: string[],
deps: WindowsMpvLaunchDeps,
): { ok: boolean; mpvPath: string } {
const mpvPath = resolveWindowsMpvPath(deps);
if (!mpvPath) {
deps.showError(
'SubMiner mpv launcher',
'Could not find mpv.exe. Install mpv and add it to PATH, or set SUBMINER_MPV_PATH.',
);
return { ok: false, mpvPath: '' };
}
try {
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets));
return { ok: true, mpvPath };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.showError('SubMiner mpv launcher', `Failed to launch mpv.\nPath: ${mpvPath}\n${message}`);
return { ok: false, mpvPath };
}
}
export function createWindowsMpvLaunchDeps(options: {
getEnv?: (name: string) => string | undefined;
fileExists?: (candidate: string) => boolean;
showError: (title: string, content: string) => void;
}): WindowsMpvLaunchDeps {
return {
getEnv: options.getEnv ?? ((name) => process.env[name]),
runWhere: () => {
const result = spawnSync('where.exe', ['mpv.exe'], {
encoding: 'utf8',
windowsHide: true,
});
return {
status: result.status,
stdout: result.stdout ?? '',
error: result.error ?? undefined,
};
},
fileExists:
options.fileExists ??
((candidate) => {
try {
return fs.statSync(candidate).isFile();
} catch {
return false;
}
}),
spawnDetached: (command, args) => {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
},
showError: options.showError,
};
}

View File

@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyWindowsMpvShortcuts,
buildWindowsMpvShortcutDetails,
detectWindowsMpvShortcuts,
resolveWindowsMpvShortcutPaths,
resolveWindowsStartMenuProgramsDir,
} from './windows-mpv-shortcuts';
test('resolveWindowsStartMenuProgramsDir derives Programs folder from APPDATA', () => {
assert.equal(
resolveWindowsStartMenuProgramsDir('C:\\Users\\tester\\AppData\\Roaming'),
'C:\\Users\\tester\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs',
);
});
test('resolveWindowsMpvShortcutPaths builds start menu and desktop lnk paths', () => {
const paths = resolveWindowsMpvShortcutPaths({
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
desktopDir: 'C:\\Users\\tester\\Desktop',
});
assert.equal(
paths.startMenuPath,
'C:\\Users\\tester\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\SubMiner mpv.lnk',
);
assert.equal(paths.desktopPath, 'C:\\Users\\tester\\Desktop\\SubMiner mpv.lnk');
});
test('buildWindowsMpvShortcutDetails targets SubMiner.exe with --launch-mpv', () => {
assert.deepEqual(buildWindowsMpvShortcutDetails('C:\\Apps\\SubMiner\\SubMiner.exe'), {
target: 'C:\\Apps\\SubMiner\\SubMiner.exe',
args: '--launch-mpv',
cwd: 'C:\\Apps\\SubMiner',
description: 'Launch mpv with the SubMiner profile',
icon: 'C:\\Apps\\SubMiner\\SubMiner.exe',
iconIndex: 0,
});
});
test('detectWindowsMpvShortcuts reflects existing shortcuts', () => {
const detected = detectWindowsMpvShortcuts(
{
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
},
(candidate) => candidate === 'C:\\Desktop\\SubMiner mpv.lnk',
);
assert.deepEqual(detected, {
startMenuInstalled: false,
desktopInstalled: true,
});
});
test('applyWindowsMpvShortcuts creates enabled shortcuts and removes disabled ones', () => {
const writes: string[] = [];
const removes: string[] = [];
const result = applyWindowsMpvShortcuts({
preferences: {
startMenuEnabled: true,
desktopEnabled: false,
},
paths: {
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
},
exePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
writeShortcutLink: (shortcutPath, operation, details) => {
writes.push(`${shortcutPath}|${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/);
});

View File

@@ -0,0 +1,117 @@
import fs from 'node:fs';
import path from 'node:path';
export const WINDOWS_MPV_SHORTCUT_NAME = 'SubMiner mpv.lnk';
export interface WindowsMpvShortcutPaths {
startMenuPath: string;
desktopPath: string;
}
export interface WindowsShortcutLinkDetails {
target: string;
args?: string;
cwd?: string;
description?: string;
icon?: string;
iconIndex?: number;
}
export interface WindowsMpvShortcutInstallResult {
ok: boolean;
status: 'installed' | 'skipped' | 'failed';
message: string;
}
export function resolveWindowsStartMenuProgramsDir(appDataDir: string): string {
return path.join(appDataDir, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
}
export function resolveWindowsMpvShortcutPaths(options: {
appDataDir: string;
desktopDir: string;
}): WindowsMpvShortcutPaths {
return {
startMenuPath: path.join(resolveWindowsStartMenuProgramsDir(options.appDataDir), WINDOWS_MPV_SHORTCUT_NAME),
desktopPath: path.join(options.desktopDir, WINDOWS_MPV_SHORTCUT_NAME),
};
}
export function detectWindowsMpvShortcuts(
paths: WindowsMpvShortcutPaths,
existsSync: (candidate: string) => boolean = fs.existsSync,
): { startMenuInstalled: boolean; desktopInstalled: boolean } {
return {
startMenuInstalled: existsSync(paths.startMenuPath),
desktopInstalled: existsSync(paths.desktopPath),
};
}
export function buildWindowsMpvShortcutDetails(exePath: string): WindowsShortcutLinkDetails {
return {
target: exePath,
args: '--launch-mpv',
cwd: path.dirname(exePath),
description: 'Launch mpv with the SubMiner profile',
icon: exePath,
iconIndex: 0,
};
}
export function applyWindowsMpvShortcuts(options: {
preferences: { startMenuEnabled: boolean; desktopEnabled: boolean };
paths: WindowsMpvShortcutPaths;
exePath: string;
writeShortcutLink: (
shortcutPath: string,
operation: 'create' | 'update' | 'replace',
details: WindowsShortcutLinkDetails,
) => boolean;
rmSync?: (candidate: string, options: { force: true }) => void;
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
}): WindowsMpvShortcutInstallResult {
const rmSync = options.rmSync ?? fs.rmSync;
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
const details = buildWindowsMpvShortcutDetails(options.exePath);
const failures: string[] = [];
const ensureShortcut = (shortcutPath: string): void => {
mkdirSync(path.dirname(shortcutPath), { recursive: true });
const ok = options.writeShortcutLink(shortcutPath, '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.',
};
}

View File

@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { AnkiConnectConfig } from '../../types';
import {
getPreferredYomitanAnkiServerUrl,
shouldForceOverrideYomitanAnkiServer,
} from './yomitan-anki-server';
function createConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
return {
enabled: false,
url: 'http://127.0.0.1:8765',
proxy: {
enabled: true,
host: '127.0.0.1',
port: 8766,
upstreamUrl: 'http://127.0.0.1:8765',
},
...overrides,
} as AnkiConnectConfig;
}
test('prefers upstream AnkiConnect when SubMiner integration is disabled', () => {
const config = createConfig({
enabled: false,
proxy: {
enabled: true,
host: '127.0.0.1',
port: 8766,
upstreamUrl: 'http://127.0.0.1:8765',
},
});
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:8765');
assert.equal(shouldForceOverrideYomitanAnkiServer(config), false);
});
test('prefers SubMiner proxy when SubMiner integration and proxy are enabled', () => {
const config = createConfig({
enabled: true,
proxy: {
enabled: true,
host: '127.0.0.1',
port: 9988,
upstreamUrl: 'http://127.0.0.1:8765',
},
});
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:9988');
assert.equal(shouldForceOverrideYomitanAnkiServer(config), true);
});
test('falls back to upstream AnkiConnect when proxy transport is disabled', () => {
const config = createConfig({
enabled: true,
proxy: {
enabled: false,
host: '127.0.0.1',
port: 8766,
upstreamUrl: 'http://127.0.0.1:8765',
},
});
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:8765');
assert.equal(shouldForceOverrideYomitanAnkiServer(config), false);
});

View File

@@ -0,0 +1,15 @@
import type { AnkiConnectConfig } from '../../types';
export function getPreferredYomitanAnkiServerUrl(config: AnkiConnectConfig): string {
if (config.enabled === true && config.proxy?.enabled === true) {
const host = config.proxy.host || '127.0.0.1';
const port = config.proxy.port || 8766;
return `http://${host}:${port}`;
}
return config.url || 'http://127.0.0.1:8765';
}
export function shouldForceOverrideYomitanAnkiServer(config: AnkiConnectConfig): boolean {
return config.enabled === true && config.proxy?.enabled === true;
}

View File

@@ -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/);
});

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