mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
Prepare Windows release and signing process (#16)
This commit is contained in:
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
|
||||
119
.github/workflows/release.yml
vendored
119
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
52
Makefile
52
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-plugin uninstall uninstall-linux uninstall-macos print-dirs pretty ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows install-plugin uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
||||
|
||||
APP_NAME := subminer
|
||||
THEME_SOURCE := assets/themes/subminer.rasi
|
||||
@@ -20,11 +20,6 @@ MACOS_DATA_DIR ?= $(HOME)/Library/Application Support/SubMiner
|
||||
MACOS_APP_DIR ?= $(HOME)/Applications
|
||||
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
||||
|
||||
# mpv plugin install directories.
|
||||
MPV_CONFIG_DIR ?= $(HOME)/.config/mpv
|
||||
MPV_SCRIPTS_DIR ?= $(MPV_CONFIG_DIR)/scripts
|
||||
MPV_SCRIPT_OPTS_DIR ?= $(MPV_CONFIG_DIR)/script-opts
|
||||
|
||||
# If building from source, the AppImage will typically land in release/.
|
||||
APPIMAGE_SRC := $(firstword $(wildcard release/SubMiner-*.AppImage))
|
||||
MACOS_APP_SRC := $(firstword $(wildcard release/*.app release/*/*.app))
|
||||
@@ -41,6 +36,17 @@ else
|
||||
PLATFORM := unknown
|
||||
endif
|
||||
|
||||
WINDOWS_APPDATA ?= $(if $(APPDATA),$(subst \,/,$(APPDATA)),$(HOME)/AppData/Roaming)
|
||||
|
||||
# mpv plugin install directories.
|
||||
ifeq ($(PLATFORM),windows)
|
||||
MPV_CONFIG_DIR ?= $(WINDOWS_APPDATA)/mpv
|
||||
else
|
||||
MPV_CONFIG_DIR ?= $(HOME)/.config/mpv
|
||||
endif
|
||||
MPV_SCRIPTS_DIR ?= $(MPV_CONFIG_DIR)/scripts
|
||||
MPV_SCRIPT_OPTS_DIR ?= $(MPV_CONFIG_DIR)/script-opts
|
||||
|
||||
help:
|
||||
@printf '%s\n' \
|
||||
"Targets:" \
|
||||
@@ -58,6 +64,7 @@ help:
|
||||
" dev-stop Stop a running local Electron app" \
|
||||
" install-linux Install Linux wrapper/theme/app artifacts" \
|
||||
" install-macos Install macOS wrapper/theme/app artifacts" \
|
||||
" install-windows Install Windows mpv plugin artifacts" \
|
||||
" install-plugin Install mpv Lua plugin and plugin config" \
|
||||
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
||||
"" \
|
||||
@@ -65,6 +72,7 @@ help:
|
||||
" deps Install JS dependencies (root + texthooker-ui)" \
|
||||
" uninstall-linux Remove Linux install artifacts" \
|
||||
" uninstall-macos Remove macOS install artifacts" \
|
||||
" uninstall-windows Remove Windows mpv plugin artifacts" \
|
||||
" print-dirs Show resolved install locations" \
|
||||
"" \
|
||||
"Variables:" \
|
||||
@@ -74,7 +82,7 @@ help:
|
||||
" LINUX_DATA_DIR=... Override Linux app data dir" \
|
||||
" MACOS_DATA_DIR=... Override macOS app data dir" \
|
||||
" MACOS_APP_DIR=... Override macOS app install dir (default: $$HOME/Applications)" \
|
||||
" MPV_CONFIG_DIR=... Override mpv config dir (default: $$HOME/.config/mpv)"
|
||||
" MPV_CONFIG_DIR=... Override mpv config dir (default: $$HOME/.config/mpv or %APPDATA%/mpv on Windows)"
|
||||
|
||||
print-dirs:
|
||||
@printf '%s\n' \
|
||||
@@ -85,6 +93,10 @@ print-dirs:
|
||||
"MACOS_DATA_DIR=$(MACOS_DATA_DIR)" \
|
||||
"MACOS_APP_DIR=$(MACOS_APP_DIR)" \
|
||||
"MACOS_APP_DEST=$(MACOS_APP_DEST)" \
|
||||
"WINDOWS_APPDATA=$(WINDOWS_APPDATA)" \
|
||||
"MPV_CONFIG_DIR=$(MPV_CONFIG_DIR)" \
|
||||
"MPV_SCRIPTS_DIR=$(MPV_SCRIPTS_DIR)" \
|
||||
"MPV_SCRIPT_OPTS_DIR=$(MPV_SCRIPT_OPTS_DIR)" \
|
||||
"APPIMAGE_SRC=$(APPIMAGE_SRC)" \
|
||||
"MACOS_APP_SRC=$(MACOS_APP_SRC)" \
|
||||
"MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)"
|
||||
@@ -105,6 +117,7 @@ build:
|
||||
@case "$(PLATFORM)" in \
|
||||
linux) $(MAKE) --no-print-directory build-linux ;; \
|
||||
macos) $(MAKE) --no-print-directory build-macos ;; \
|
||||
windows) printf '%s\n' "[INFO] Windows builds run via: bun run build:win" ;; \
|
||||
*) printf '%s\n' "[ERROR] Unsupported OS for this Makefile target: $(PLATFORM)"; exit 1 ;; \
|
||||
esac
|
||||
|
||||
@@ -113,6 +126,7 @@ install:
|
||||
@case "$(PLATFORM)" in \
|
||||
linux) $(MAKE) --no-print-directory install-linux ;; \
|
||||
macos) $(MAKE) --no-print-directory install-macos ;; \
|
||||
windows) $(MAKE) --no-print-directory install-windows ;; \
|
||||
*) printf '%s\n' "[ERROR] Unsupported OS for this Makefile target: $(PLATFORM)"; exit 1 ;; \
|
||||
esac
|
||||
|
||||
@@ -210,18 +224,31 @@ install-macos: build-launcher
|
||||
fi
|
||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
|
||||
|
||||
install-windows:
|
||||
@printf '%s\n' "[INFO] Installing Windows mpv plugin artifacts"
|
||||
@$(MAKE) --no-print-directory install-plugin
|
||||
|
||||
install-plugin:
|
||||
@printf '%s\n' "[INFO] Installing mpv plugin artifacts"
|
||||
@install -d "$(MPV_SCRIPTS_DIR)"
|
||||
@rm -f "$(MPV_SCRIPTS_DIR)/subminer.lua"
|
||||
@rm -f "$(MPV_SCRIPTS_DIR)/subminer.lua" "$(MPV_SCRIPTS_DIR)/subminer-loader.lua"
|
||||
@install -d "$(MPV_SCRIPTS_DIR)/subminer"
|
||||
@install -d "$(MPV_SCRIPT_OPTS_DIR)"
|
||||
@cp -R ./plugin/subminer/. "$(MPV_SCRIPTS_DIR)/subminer/"
|
||||
@install -m 0644 "./$(PLUGIN_CONF)" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||
@if [ "$(PLATFORM)" = "windows" ]; then \
|
||||
bun ./scripts/configure-plugin-binary-path.mjs "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf" "$(CURDIR)" win32; \
|
||||
fi
|
||||
@printf '%s\n' "Installed to:" " $(MPV_SCRIPTS_DIR)/subminer/main.lua" " $(MPV_SCRIPTS_DIR)/subminer/" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||
|
||||
# Uninstall behavior kept unchanged by default.
|
||||
uninstall: uninstall-linux
|
||||
uninstall:
|
||||
@printf '%s\n' "[INFO] Detected platform: $(PLATFORM)"
|
||||
@case "$(PLATFORM)" in \
|
||||
linux) $(MAKE) --no-print-directory uninstall-linux ;; \
|
||||
macos) $(MAKE) --no-print-directory uninstall-macos ;; \
|
||||
windows) $(MAKE) --no-print-directory uninstall-windows ;; \
|
||||
*) printf '%s\n' "[ERROR] Unsupported OS for this Makefile target: $(PLATFORM)"; exit 1 ;; \
|
||||
esac
|
||||
|
||||
uninstall-linux:
|
||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||
@@ -233,3 +260,8 @@ uninstall-macos:
|
||||
@rm -f "$(MACOS_DATA_DIR)/themes/$(THEME_FILE)"
|
||||
@rm -rf "$(MACOS_APP_DEST)"
|
||||
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
|
||||
|
||||
uninstall-windows:
|
||||
@rm -rf "$(MPV_SCRIPTS_DIR)/subminer"
|
||||
@rm -f "$(MPV_SCRIPTS_DIR)/subminer.lua" "$(MPV_SCRIPTS_DIR)/subminer-loader.lua" "$(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||
@printf '%s\n' "Removed:" " $(MPV_SCRIPTS_DIR)/subminer" " $(MPV_SCRIPT_OPTS_DIR)/subminer.conf"
|
||||
|
||||
49
README.md
49
README.md
@@ -5,7 +5,7 @@
|
||||
<br /><br />
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[]()
|
||||
[]()
|
||||
[](https://docs.subminer.moe)
|
||||
|
||||
</div>
|
||||
@@ -20,17 +20,24 @@
|
||||
|
||||
<br />
|
||||
|
||||
Initial packaged Windows support is now available alongside the existing Linux and macOS builds.
|
||||
|
||||
## What it does
|
||||
|
||||
SubMiner is an Electron overlay that sits on top of mpv. It turns your video player into a full sentence-mining workstation:
|
||||
|
||||
- **Dictionary lookups** — Yomitan popups on subtitles with hover or full keyboard-driven navigation; hover-aware auto-pause keeps playback in sync
|
||||
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and AI-powered translation
|
||||
- **Reading annotations** — N+1 targeting, frequency highlighting, and JLPT underlining while you watch
|
||||
- **Subtitle tools** — Jimaku downloads, alass/ffsubsync sync, and YouTube subtitle generation via manual-track reuse plus whisper.cpp fallback with optional AI cleanup
|
||||
- **Texthooker** — Built-in texthooker page and annotated websocket API for external clients
|
||||
- **Immersion tracking** — SQLite-powered stats on watch time and mining activity
|
||||
- **Integrations** — Jellyfin remote playback, AniList episode progress, and AnkiConnect auto-enrichment
|
||||
- **Hover to look up** — Yomitan dictionary popups directly on subtitles
|
||||
- **Keyboard-driven lookup mode** — Navigate token-by-token, keep lookup open across tokens, and control popup scrolling/audio/mining without leaving the overlay
|
||||
- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation
|
||||
- **Instant auto-enrichment** — Optional local AnkiConnect proxy enriches new Yomitan cards immediately
|
||||
- **Reading annotations** — Combines N+1 targeting, frequency-dictionary highlighting, and JLPT underlining while you read
|
||||
- **Hover-aware playback** — By default, hovering subtitle text pauses mpv and resumes on mouse leave (`subtitleStyle.autoPauseVideoOnHover`)
|
||||
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
|
||||
- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity
|
||||
- **Custom texthooker page** — Built-in custom texthooker page and websocket, no extra setup
|
||||
- **Annotated websocket API** — Dedicated annotation feed can serve bundled texthooker or external clients with rendered `sentence` HTML plus structured `tokens`
|
||||
- **Jellyfin integration** — Remote playback setup, cast device mode, and direct playback launch
|
||||
- **AniList progress** — Track episode completion and push watching progress automatically
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -49,14 +56,21 @@ chmod +x ~/.local/bin/subminer
|
||||
> [!NOTE]
|
||||
> The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. Make sure `bun` is on your `PATH`.
|
||||
|
||||
**From source** or **macOS** — initialize submodules first (`git submodule update --init --recursive`). Bundled Yomitan is built natively with Bun from the `vendor/subminer-yomitan` submodule into `build/yomitan` during `bun run build`, so Bun is the only JS runtime/package manager required for source builds. Full install guide: [docs.subminer.moe/installation#from-source](https://docs.subminer.moe/installation#from-source).
|
||||
**macOS (DMG/ZIP):** download the latest packaged build from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
|
||||
|
||||
**Windows (Installer/ZIP):** download the latest `SubMiner-<version>.exe` installer or portable `.zip` from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Keep `mpv` installed and available on `PATH`.
|
||||
|
||||
**From source** — initialize submodules first (`git submodule update --init --recursive`). 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
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
id: TASK-117
|
||||
title: Prepare initial Windows release docs and version bump
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-08 15:17'
|
||||
updated_date: '2026-03-08 15:17'
|
||||
labels:
|
||||
- release
|
||||
- docs
|
||||
- windows
|
||||
dependencies: []
|
||||
references:
|
||||
- package.json
|
||||
- README.md
|
||||
- ../subminer-docs/installation.md
|
||||
- ../subminer-docs/usage.md
|
||||
- ../subminer-docs/changelog.md
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Prepare the initial packaged Windows release by bumping the app version and refreshing the release-facing README/backlog/docs surfaces so install and direct-command guidance no longer reads Linux-only.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 App version is bumped for the Windows release cut
|
||||
- [x] #2 README and sibling docs describe Windows packaged installation alongside Linux/macOS guidance
|
||||
- [x] #3 Backlog records the release-doc/version update with the modified references
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Bump the package version for the release cut.
|
||||
2. Update the root README install/start guidance to mention Windows packaged builds.
|
||||
3. Patch the sibling docs repo installation, usage, and changelog pages for the Windows release.
|
||||
4. Record the work in Backlog and run targeted verification on the touched surfaces.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
The public README still advertised Linux/macOS only, while the sibling docs had Windows-specific runtime notes but no actual Windows install section and several direct-command examples still assumed `SubMiner.AppImage`.
|
||||
|
||||
Bumped `package.json` to `0.5.0`, expanded the README platform/install copy to include Windows, added a Windows install section to `../subminer-docs/installation.md`, clarified in `../subminer-docs/usage.md` that direct packaged-app examples use `SubMiner.exe` on Windows, and added a `v0.5.0` changelog entry covering the initial Windows release plus the latest overlay behavior polish.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Prepared the initial Windows release documentation pass and version bump. `package.json` now reports `0.5.0`. The root `README.md` now advertises Linux, macOS, and Windows support, includes Windows packaged-install guidance, and clarifies first-launch behavior across platforms. In the sibling docs repo, `installation.md` now includes a dedicated Windows install section, `usage.md` explains that direct packaged-app examples use `SubMiner.exe` on Windows, and `changelog.md` now includes the `v0.5.0` release notes for the initial Windows build and recent overlay behavior changes.
|
||||
|
||||
Verification: targeted `bun run tsc --noEmit -p tsconfig.typecheck.json` in the app repo and `bun run docs:build` in `../subminer-docs`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
id: TASK-118
|
||||
title: Add Windows release build and SignPath signing
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-08 15:17'
|
||||
updated_date: '2026-03-08 15:17'
|
||||
labels:
|
||||
- release
|
||||
- windows
|
||||
- signing
|
||||
dependencies: []
|
||||
references:
|
||||
- .github/workflows/release.yml
|
||||
- build/installer.nsh
|
||||
- build/signpath-windows-artifact-config.xml
|
||||
- package.json
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Extend the tag-driven release workflow so Windows artifacts are built on GitHub-hosted runners and submitted to SignPath for free open-source Authenticode signing, while preserving the existing macOS notarization path.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Release workflow builds Windows installer and ZIP artifacts on `windows-latest`
|
||||
- [x] #2 Workflow submits unsigned Windows artifacts to SignPath and uploads the signed outputs for release publication
|
||||
- [x] #3 Repository includes a checked-in SignPath artifact-configuration source of truth for the Windows release files
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Inspect the existing release workflow and current Windows packaging configuration.
|
||||
2. Add a Windows release job that builds unsigned artifacts, uploads them as a workflow artifact, and submits them to SignPath.
|
||||
3. Update the release aggregation job to publish signed Windows assets and mention Windows install steps in the generated release notes.
|
||||
4. Check in the Windows SignPath artifact configuration XML used to define what gets signed.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
The repository already had Windows packaging configuration (`build:win`, NSIS include script, Windows helper asset packaging), but the release workflow still built Linux and macOS only.
|
||||
|
||||
Added a `build-windows` job to `.github/workflows/release.yml` that runs on `windows-latest`, validates required SignPath secrets, builds unsigned Windows artifacts, uploads them with `actions/upload-artifact@v4`, and then calls the official `signpath/github-action-submit-signing-request@v2` action to retrieve signed outputs.
|
||||
|
||||
Checked in `build/signpath-windows-artifact-config.xml` as the source-of-truth artifact configuration for SignPath. It signs the top-level NSIS installer EXE and deep-signs `.exe` and `.dll` files inside the portable ZIP artifact.
|
||||
|
||||
Updated the release aggregation job to download the signed Windows artifacts and added a Windows install section to the generated GitHub release body.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Windows release publishing is now wired into the tag-driven workflow. `.github/workflows/release.yml` builds Windows artifacts on `windows-latest`, submits them to SignPath using the official GitHub action, and publishes the signed `.exe` and `.zip` outputs alongside the Linux and macOS artifacts. The workflow now requests the additional `actions: read` permission required by the SignPath GitHub integration, and the generated release notes now include Windows installation steps.
|
||||
|
||||
The checked-in `build/signpath-windows-artifact-config.xml` file defines the SignPath artifact structure expected by the workflow artifact ZIP: sign the top-level `SubMiner-*.exe` installer and deep-sign `.exe` and `.dll` files inside `SubMiner-*.zip`.
|
||||
|
||||
Verification: workflow/static changes were checked with `git diff --check` on the touched files. Actual signing requires configured SignPath secrets and a matching artifact configuration in your SignPath project.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
153
build/installer.nsh
Normal file
153
build/installer.nsh
Normal 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
|
||||
21
build/signpath-windows-artifact-config.xml
Normal file
21
build/signpath-windows-artifact-config.xml
Normal 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>
|
||||
4
changes/windows-background-reuse.md
Normal file
4
changes/windows-background-reuse.md
Normal 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.
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
24
launcher/log.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { getDefaultMpvLogFile } from './types.js';
|
||||
|
||||
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
const resolved = getDefaultMpvLogFile({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
path.normalize(resolved),
|
||||
path.normalize(
|
||||
path.join(
|
||||
'C:\\Users\\tester\\AppData\\Roaming',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -7,22 +7,26 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
|
||||
const sequence: Array<SetupState | null> = [
|
||||
null,
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
},
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'skipped',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'skipped',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,23 +54,27 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
||||
if (reads === 1) return null;
|
||||
if (reads === 2) {
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'installed',
|
||||
};
|
||||
},
|
||||
launchSetupApp: () => {
|
||||
@@ -88,13 +96,15 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: () => ({
|
||||
version: 1,
|
||||
version: 2,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
}),
|
||||
launchSetupApp: () => undefined,
|
||||
sleep: async () => undefined,
|
||||
|
||||
@@ -3,7 +3,14 @@ import os from 'node:os';
|
||||
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
||||
|
||||
export const ROFI_THEME_FILE = 'subminer.rasi';
|
||||
export const DEFAULT_SOCKET_PATH = '/tmp/subminer-socket';
|
||||
export function getDefaultSocketPath(platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform === 'win32') {
|
||||
return '\\\\.\\pipe\\subminer-socket';
|
||||
}
|
||||
return '/tmp/subminer-socket';
|
||||
}
|
||||
|
||||
export const DEFAULT_SOCKET_PATH = getDefaultSocketPath();
|
||||
export const DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS = ['ja', 'jpn'];
|
||||
export const DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS = ['en', 'eng'];
|
||||
export const YOUTUBE_SUB_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
|
||||
@@ -22,13 +29,21 @@ export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
|
||||
'subminer',
|
||||
'youtube-subs',
|
||||
);
|
||||
export const DEFAULT_MPV_LOG_FILE = path.join(
|
||||
os.homedir(),
|
||||
'.config',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
);
|
||||
export function getDefaultMpvLogFile(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
appDataDir?: string;
|
||||
}): string {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const homeDir = options?.homeDir ?? os.homedir();
|
||||
const baseDir =
|
||||
platform === 'win32'
|
||||
? path.join(options?.appDataDir?.trim() || path.join(homeDir, 'AppData', 'Roaming'), 'SubMiner')
|
||||
: path.join(homeDir, '.config', 'SubMiner');
|
||||
return path.join(baseDir, 'logs', `SubMiner-${new Date().toISOString().slice(0, 10)}.log`);
|
||||
}
|
||||
|
||||
export const DEFAULT_MPV_LOG_FILE = getDefaultMpvLogFile();
|
||||
export const DEFAULT_YOUTUBE_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
|
||||
export const DEFAULT_JIMAKU_API_BASE_URL = 'https://jimaku.cc';
|
||||
export const DEFAULT_MPV_SUBMINER_ARGS = [
|
||||
|
||||
191
launcher/util.ts
191
launcher/util.ts
@@ -18,14 +18,139 @@ export function isExecutable(filePath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function commandExists(command: string): boolean {
|
||||
const pathEnv = process.env.PATH ?? '';
|
||||
function isRunnableFile(filePath: string): boolean {
|
||||
try {
|
||||
if (!fs.statSync(filePath).isFile()) return false;
|
||||
return process.platform === 'win32' ? true : isExecutable(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathLikeCommand(command: string): boolean {
|
||||
return (
|
||||
command.includes('/') ||
|
||||
command.includes('\\') ||
|
||||
/^[A-Za-z]:[\\/]/.test(command) ||
|
||||
command.startsWith('.')
|
||||
);
|
||||
}
|
||||
|
||||
function getWindowsPathExts(): string[] {
|
||||
const raw = process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD';
|
||||
return raw
|
||||
.split(';')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
export function getPathEnv(): string {
|
||||
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === 'path');
|
||||
return pathKey ? (process.env[pathKey] ?? '') : '';
|
||||
}
|
||||
|
||||
function resolveExecutablePath(command: string): string | null {
|
||||
const tryCandidate = (candidate: string): string | null =>
|
||||
isRunnableFile(candidate) ? candidate : null;
|
||||
|
||||
const resolveWindowsCandidate = (candidate: string): string | null => {
|
||||
const direct = tryCandidate(candidate);
|
||||
if (direct) return direct;
|
||||
if (path.extname(candidate)) return null;
|
||||
for (const ext of getWindowsPathExts()) {
|
||||
const withExt = tryCandidate(`${candidate}${ext}`);
|
||||
if (withExt) return withExt;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (isPathLikeCommand(command)) {
|
||||
const resolved = path.resolve(resolvePathMaybe(command));
|
||||
return process.platform === 'win32' ? resolveWindowsCandidate(resolved) : tryCandidate(resolved);
|
||||
}
|
||||
|
||||
const pathEnv = getPathEnv();
|
||||
for (const dir of pathEnv.split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
const full = path.join(dir, command);
|
||||
if (isExecutable(full)) return true;
|
||||
const candidate = path.join(dir, command);
|
||||
const resolved =
|
||||
process.platform === 'win32' ? resolveWindowsCandidate(candidate) : tryCandidate(candidate);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeWindowsBashArg(value: string): string {
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
|
||||
if (!driveMatch) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const [, driveLetter, remainder] = driveMatch;
|
||||
return `/mnt/${driveLetter!.toLowerCase()}/${remainder}`;
|
||||
}
|
||||
|
||||
function resolveGitBashExecutable(): string | null {
|
||||
const directCandidates = [
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
||||
];
|
||||
for (const candidate of directCandidates) {
|
||||
if (isRunnableFile(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const gitExecutable = resolveExecutablePath('git');
|
||||
if (!gitExecutable) return null;
|
||||
const gitDir = path.dirname(gitExecutable);
|
||||
const inferredCandidates = [
|
||||
path.resolve(gitDir, '..', 'bin', 'bash.exe'),
|
||||
path.resolve(gitDir, '..', 'usr', 'bin', 'bash.exe'),
|
||||
];
|
||||
for (const candidate of inferredCandidates) {
|
||||
if (isRunnableFile(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveWindowsBashTarget(): {
|
||||
command: string;
|
||||
flavor: 'git' | 'wsl';
|
||||
} {
|
||||
const gitBash = resolveGitBashExecutable();
|
||||
if (gitBash) {
|
||||
return { command: gitBash, flavor: 'git' };
|
||||
}
|
||||
return {
|
||||
command: resolveExecutablePath('bash') ?? 'bash',
|
||||
flavor: 'wsl',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWindowsShellArg(value: string, flavor: 'git' | 'wsl'): string {
|
||||
if (!isPathLikeCommand(value)) {
|
||||
return value;
|
||||
}
|
||||
return flavor === 'git' ? value.replace(/\\/g, '/') : normalizeWindowsBashArg(value);
|
||||
}
|
||||
|
||||
function readShebang(filePath: string): string {
|
||||
try {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(160);
|
||||
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
||||
return buffer.toString('utf8', 0, bytesRead).split(/\r?\n/, 1)[0] ?? '';
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function commandExists(command: string): boolean {
|
||||
return resolveExecutablePath(command) !== null;
|
||||
}
|
||||
|
||||
export function resolvePathMaybe(input: string): string {
|
||||
@@ -116,6 +241,51 @@ export function inferWhisperLanguage(langCodes: string[], fallback: string): str
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function resolveCommandInvocation(
|
||||
executable: string,
|
||||
args: string[],
|
||||
): { command: string; args: string[] } {
|
||||
if (process.platform !== 'win32') {
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
const resolvedExecutable = resolveExecutablePath(executable) ?? executable;
|
||||
const extension = path.extname(resolvedExecutable).toLowerCase();
|
||||
if (extension === '.ps1') {
|
||||
return {
|
||||
command: 'powershell.exe',
|
||||
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolvedExecutable, ...args],
|
||||
};
|
||||
}
|
||||
|
||||
if (extension === '.sh') {
|
||||
const bashTarget = resolveWindowsBashTarget();
|
||||
return {
|
||||
command: bashTarget.command,
|
||||
args: [
|
||||
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
||||
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!extension) {
|
||||
const shebang = readShebang(resolvedExecutable);
|
||||
if (/^#!.*\b(?:sh|bash)\b/i.test(shebang)) {
|
||||
const bashTarget = resolveWindowsBashTarget();
|
||||
return {
|
||||
command: bashTarget.command,
|
||||
args: [
|
||||
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
|
||||
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { command: resolvedExecutable, args };
|
||||
}
|
||||
|
||||
export function runExternalCommand(
|
||||
executable: string,
|
||||
args: string[],
|
||||
@@ -129,8 +299,13 @@ export function runExternalCommand(
|
||||
const streamOutput = opts.streamOutput === true;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
log('debug', configuredLogLevel, `[${commandLabel}] spawn: ${executable} ${args.join(' ')}`);
|
||||
const child = spawn(executable, args, {
|
||||
const target = resolveCommandInvocation(executable, args);
|
||||
log(
|
||||
'debug',
|
||||
configuredLogLevel,
|
||||
`[${commandLabel}] spawn: ${target.command} ${target.args.join(' ')}`,
|
||||
);
|
||||
const child = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...opts.env },
|
||||
});
|
||||
@@ -201,7 +376,7 @@ export function runExternalCommand(
|
||||
`[${commandLabel}] exit code ${code ?? 1}`,
|
||||
);
|
||||
if (code !== 0 && !allowFailure) {
|
||||
const commandString = `${executable} ${args.join(' ')}`;
|
||||
const commandString = `${target.command} ${target.args.join(' ')}`;
|
||||
reject(
|
||||
new Error(`Command failed (${commandString}): ${stderr.trim() || `exit code ${code}`}`),
|
||||
);
|
||||
|
||||
49
package.json
49
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "subminer",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -11,8 +11,9 @@
|
||||
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
||||
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
|
||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && electron dist/scripts/test-yomitan-parser.js",
|
||||
"build:yomitan": "cd vendor/subminer-yomitan && bun install --frozen-lockfile && bun run build -- --target chrome && rm -rf ../../build/yomitan && mkdir -p ../../build/yomitan && unzip -qo builds/yomitan-chrome.zip -d ../../build/yomitan",
|
||||
"build": "bun run build:yomitan && tsc -p tsconfig.json && bun run build:renderer && cp src/renderer/index.html src/renderer/style.css dist/renderer/ && cp -r src/renderer/fonts dist/renderer/ && bash scripts/build-macos-helper.sh",
|
||||
"build:yomitan": "bun scripts/build-yomitan.mjs",
|
||||
"build:assets": "bun scripts/prepare-build-assets.mjs",
|
||||
"build": "bun run build:yomitan && tsc -p tsconfig.json && bun run build:renderer && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"changelog:build": "bun run scripts/build-changelog.ts build",
|
||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||
@@ -26,30 +27,30 @@
|
||||
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts",
|
||||
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js",
|
||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua",
|
||||
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/mpv.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts",
|
||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts",
|
||||
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||
"test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts",
|
||||
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
|
||||
"test:immersion:sqlite": "tsc -p tsconfig.json && bun run test:immersion:sqlite:dist",
|
||||
"test:immersion:sqlite": "bun run tsc && bun run test:immersion:sqlite:dist",
|
||||
"test:src": "bun scripts/run-test-lane.mjs bun-src-full",
|
||||
"test:launcher:unit:src": "bun scripts/run-test-lane.mjs bun-launcher-unit",
|
||||
"test:launcher:env:src": "bun run test:launcher:smoke:src && bun run test:plugin:src",
|
||||
"test:env": "bun run test:launcher:env:src && bun run test:immersion:sqlite:src",
|
||||
"test:runtime:compat": "tsc -p tsconfig.json && bun test dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/overlay-manager.test.js dist/main/config-validation.test.js dist/main/runtime/registry.test.js dist/main/runtime/startup-config.test.js",
|
||||
"test:runtime:compat": "bun run tsc && bun test dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/overlay-manager.test.js dist/main/config-validation.test.js dist/main/runtime/registry.test.js dist/main/runtime/startup-config.test.js",
|
||||
"test:node:compat": "bun run test:runtime:compat",
|
||||
"test:full": "bun run test:src && bun run test:launcher:unit:src && bun run test:runtime:compat",
|
||||
"test:full": "bun run test:src && bun run test:launcher:unit:src && bun run test:node:compat",
|
||||
"test": "bun run test:fast",
|
||||
"test:config": "bun run test:config:src",
|
||||
"test:launcher": "bun run test:launcher:src",
|
||||
"test:core": "bun run test:core:src",
|
||||
"test:subtitle": "bun run test:subtitle:src",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts && tsc -p tsconfig.json && bun test dist/main/runtime/registry.test.js",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"generate:config-example": "bun run build && bun dist/generate-config-example.js",
|
||||
"start": "bun run build && electron . --start",
|
||||
"dev": "bun run build && electron . --start --dev",
|
||||
@@ -58,7 +59,8 @@
|
||||
"build:appimage": "bun run build && electron-builder --linux AppImage",
|
||||
"build:mac": "bun run build && electron-builder --mac dmg zip",
|
||||
"build:mac:unsigned": "bun run build && env -u APPLE_ID -u APPLE_APP_SPECIFIC_PASSWORD -u APPLE_TEAM_ID -u CSC_LINK -u CSC_KEY_PASSWORD CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --mac dmg zip",
|
||||
"build:mac:zip": "bun run build && electron-builder --mac zip"
|
||||
"build:mac:zip": "bun run build && electron-builder --mac zip",
|
||||
"build:win": "bun run build && electron-builder --win nsis zip"
|
||||
},
|
||||
"keywords": [
|
||||
"anki",
|
||||
@@ -116,7 +118,26 @@
|
||||
"icon": "assets/SubMiner.png",
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist"
|
||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "dist/scripts/get-mpv-window-macos",
|
||||
"to": "scripts/get-mpv-window-macos"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis",
|
||||
"zip"
|
||||
],
|
||||
"icon": "assets/SubMiner.png"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"include": "build/installer.nsh"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
@@ -147,8 +168,8 @@
|
||||
"to": "plugin/subminer.conf"
|
||||
},
|
||||
{
|
||||
"from": "dist/scripts/get-mpv-window-macos",
|
||||
"to": "scripts/get-mpv-window-macos"
|
||||
"from": "dist/scripts/get-mpv-window-windows.ps1",
|
||||
"to": "scripts/get-mpv-window-windows.ps1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
# Path to SubMiner binary (leave empty for auto-detection)
|
||||
# Auto-detection searches common locations, including:
|
||||
# - macOS: /Applications/SubMiner.app/Contents/MacOS/SubMiner, ~/Applications/SubMiner.app/Contents/MacOS/SubMiner
|
||||
# - Windows: %LOCALAPPDATA%\Programs\SubMiner\SubMiner.exe, %ProgramFiles%\SubMiner\SubMiner.exe
|
||||
# - Linux: ~/.local/bin/SubMiner.AppImage, /opt/SubMiner/SubMiner.AppImage, /usr/local/bin/SubMiner, /usr/bin/SubMiner
|
||||
binary_path=
|
||||
|
||||
# Path to mpv IPC socket (must match input-ipc-server in mpv.conf)
|
||||
# Windows installs rewrite this to \\.\pipe\subminer-socket during installation.
|
||||
socket_path=/tmp/subminer-socket
|
||||
|
||||
# Enable texthooker WebSocket server
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
local M = {}
|
||||
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local utils = ctx.utils
|
||||
local opts = ctx.opts
|
||||
local state = ctx.state
|
||||
@@ -26,6 +27,13 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
local function binary_candidates_from_app_path(app_path)
|
||||
if environment.is_windows() then
|
||||
return {
|
||||
utils.join_path(app_path, "SubMiner.exe"),
|
||||
utils.join_path(app_path, "subminer.exe"),
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
utils.join_path(app_path, "Contents", "MacOS", "SubMiner"),
|
||||
utils.join_path(app_path, "Contents", "MacOS", "subminer"),
|
||||
@@ -43,6 +51,11 @@ function M.create(ctx)
|
||||
return true
|
||||
end
|
||||
|
||||
local function directory_exists(path)
|
||||
local info = utils.file_info(path)
|
||||
return info ~= nil and info.is_dir == true
|
||||
end
|
||||
|
||||
local function resolve_binary_candidate(candidate)
|
||||
local normalized = normalize_binary_path_candidate(candidate)
|
||||
if not normalized then
|
||||
@@ -53,6 +66,25 @@ function M.create(ctx)
|
||||
return normalized
|
||||
end
|
||||
|
||||
if environment.is_windows() then
|
||||
if not normalized:lower():match("%.exe$") then
|
||||
local with_exe = normalized .. ".exe"
|
||||
if file_exists(with_exe) then
|
||||
return with_exe
|
||||
end
|
||||
end
|
||||
|
||||
if directory_exists(normalized) then
|
||||
for _, path in ipairs(binary_candidates_from_app_path(normalized)) do
|
||||
if file_exists(path) then
|
||||
return path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
if not normalized:lower():find("%.app") then
|
||||
return nil
|
||||
end
|
||||
@@ -89,6 +121,109 @@ function M.create(ctx)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function add_search_path(search_paths, candidate)
|
||||
if type(candidate) == "string" and candidate ~= "" then
|
||||
search_paths[#search_paths + 1] = candidate
|
||||
end
|
||||
end
|
||||
|
||||
local function trim_subprocess_stdout(value)
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local trimmed = value:match("^%s*(.-)%s*$") or ""
|
||||
if trimmed == "" then
|
||||
return nil
|
||||
end
|
||||
return trimmed
|
||||
end
|
||||
|
||||
local function find_windows_binary_via_system_lookup()
|
||||
if not environment.is_windows() then
|
||||
return nil
|
||||
end
|
||||
if not mp or type(mp.command_native) ~= "function" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local script = [=[
|
||||
function Emit-FirstExistingPath {
|
||||
param([string[]]$Candidates)
|
||||
|
||||
foreach ($candidate in $Candidates) {
|
||||
if ([string]::IsNullOrWhiteSpace($candidate)) {
|
||||
continue
|
||||
}
|
||||
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
|
||||
Write-Output $candidate
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$runningProcess = Get-CimInstance Win32_Process |
|
||||
Where-Object { $_.Name -ieq 'SubMiner.exe' -or $_.Name -ieq 'subminer.exe' } |
|
||||
Select-Object -First 1 -Property ExecutablePath, CommandLine
|
||||
if ($null -ne $runningProcess) {
|
||||
Emit-FirstExistingPath @($runningProcess.ExecutablePath)
|
||||
}
|
||||
|
||||
$localAppData = [Environment]::GetFolderPath('LocalApplicationData')
|
||||
$programFiles = [Environment]::GetFolderPath('ProgramFiles')
|
||||
$programFilesX86 = ${env:ProgramFiles(x86)}
|
||||
|
||||
Emit-FirstExistingPath @(
|
||||
$(if (-not [string]::IsNullOrWhiteSpace($localAppData)) { Join-Path $localAppData 'Programs\SubMiner\SubMiner.exe' } else { $null }),
|
||||
$(if (-not [string]::IsNullOrWhiteSpace($programFiles)) { Join-Path $programFiles 'SubMiner\SubMiner.exe' } else { $null }),
|
||||
$(if (-not [string]::IsNullOrWhiteSpace($programFilesX86)) { Join-Path $programFilesX86 'SubMiner\SubMiner.exe' } else { $null }),
|
||||
'C:\SubMiner\SubMiner.exe'
|
||||
)
|
||||
|
||||
foreach ($registryPath in @(
|
||||
'HKCU:\Software\Microsoft\Windows\CurrentVersion\App Paths\SubMiner.exe',
|
||||
'HKLM:\Software\Microsoft\Windows\CurrentVersion\App Paths\SubMiner.exe',
|
||||
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\SubMiner.exe'
|
||||
)) {
|
||||
try {
|
||||
$appPath = (Get-ItemProperty -Path $registryPath -ErrorAction Stop).'(default)'
|
||||
Emit-FirstExistingPath @($appPath)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$commandPath = Get-Command SubMiner.exe -ErrorAction Stop | Select-Object -First 1 -ExpandProperty Source
|
||||
Emit-FirstExistingPath @($commandPath)
|
||||
} catch {
|
||||
}
|
||||
]=]
|
||||
|
||||
local result = mp.command_native({
|
||||
name = "subprocess",
|
||||
args = {
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script,
|
||||
},
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = false,
|
||||
})
|
||||
if not result or result.status ~= 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local candidate = trim_subprocess_stdout(result.stdout)
|
||||
if not candidate then
|
||||
return nil
|
||||
end
|
||||
|
||||
return resolve_binary_candidate(candidate)
|
||||
end
|
||||
|
||||
local function find_binary()
|
||||
local override = find_binary_override()
|
||||
if override then
|
||||
@@ -100,17 +235,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
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
local M = {}
|
||||
local BOOTSTRAP_GUARD_KEY = "__subminer_plugin_bootstrapped"
|
||||
|
||||
function M.init()
|
||||
if rawget(_G, BOOTSTRAP_GUARD_KEY) == true then
|
||||
return
|
||||
end
|
||||
rawset(_G, BOOTSTRAP_GUARD_KEY, true)
|
||||
|
||||
local input = require("mp.input")
|
||||
local mp = require("mp")
|
||||
local msg = require("mp.msg")
|
||||
|
||||
@@ -61,8 +61,9 @@ function M.create(ctx)
|
||||
aniskip.clear_aniskip_state()
|
||||
hover.clear_hover_overlay()
|
||||
process.disarm_auto_play_ready_gate()
|
||||
if state.overlay_running or state.texthooker_running then
|
||||
subminer_log("info", "lifecycle", "mpv shutting down, preserving SubMiner background process")
|
||||
if state.overlay_running then
|
||||
subminer_log("info", "lifecycle", "mpv shutting down, hiding SubMiner overlay")
|
||||
process.hide_visible_overlay()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -75,6 +76,9 @@ function M.create(ctx)
|
||||
mp.register_event("end-file", function()
|
||||
process.disarm_auto_play_ready_gate()
|
||||
hover.clear_hover_overlay()
|
||||
if state.overlay_running then
|
||||
process.hide_visible_overlay()
|
||||
end
|
||||
end)
|
||||
mp.register_event("shutdown", function()
|
||||
hover.clear_hover_overlay()
|
||||
|
||||
@@ -22,4 +22,9 @@ if not package.path:find(module_patterns, 1, true) then
|
||||
package.path = module_patterns .. package.path
|
||||
end
|
||||
|
||||
require("init").init()
|
||||
local init_module = assert(loadfile(script_dir .. "/init.lua"))()
|
||||
if type(init_module) == "table" and type(init_module.init) == "function" then
|
||||
init_module.init()
|
||||
elseif type(init_module) == "function" then
|
||||
init_module()
|
||||
end
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
local M = {}
|
||||
|
||||
local function normalize_socket_path_option(socket_path, default_socket_path)
|
||||
if type(default_socket_path) ~= "string" then
|
||||
return socket_path
|
||||
end
|
||||
|
||||
local trimmed_default = default_socket_path:match("^%s*(.-)%s*$")
|
||||
local trimmed_socket = type(socket_path) == "string" and socket_path:match("^%s*(.-)%s*$") or socket_path
|
||||
if trimmed_default ~= "\\\\.\\pipe\\subminer-socket" then
|
||||
return trimmed_socket
|
||||
end
|
||||
if type(trimmed_socket) ~= "string" or trimmed_socket == "" then
|
||||
return trimmed_default
|
||||
end
|
||||
if trimmed_socket == "/tmp/subminer-socket" or trimmed_socket == "\\tmp\\subminer-socket" then
|
||||
return trimmed_default
|
||||
end
|
||||
if trimmed_socket == "\\\\.\\pipe\\tmp\\subminer-socket" then
|
||||
return trimmed_default
|
||||
end
|
||||
return trimmed_socket
|
||||
end
|
||||
|
||||
function M.load(options_lib, default_socket_path)
|
||||
local opts = {
|
||||
binary_path = "",
|
||||
@@ -25,6 +47,7 @@ function M.load(options_lib, default_socket_path)
|
||||
}
|
||||
|
||||
options_lib.read_options(opts, "subminer")
|
||||
opts.socket_path = normalize_socket_path_option(opts.socket_path, default_socket_path)
|
||||
return opts
|
||||
end
|
||||
|
||||
|
||||
@@ -411,6 +411,28 @@ function M.create(ctx)
|
||||
show_osd("Stopped")
|
||||
end
|
||||
|
||||
local function hide_visible_overlay()
|
||||
if not binary.ensure_binary_available() then
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
return
|
||||
end
|
||||
|
||||
run_control_command_async("hide-visible-overlay", nil, function(ok, result)
|
||||
if ok then
|
||||
subminer_log("info", "process", "Visible overlay hidden")
|
||||
else
|
||||
subminer_log(
|
||||
"warn",
|
||||
"process",
|
||||
"Hide-visible-overlay command returned non-zero status: "
|
||||
.. tostring(result and result.status or "unknown")
|
||||
)
|
||||
end
|
||||
end)
|
||||
|
||||
disarm_auto_play_ready_gate()
|
||||
end
|
||||
|
||||
local function toggle_overlay()
|
||||
if not binary.ensure_binary_available() then
|
||||
subminer_log("error", "binary", "SubMiner binary not found")
|
||||
@@ -511,6 +533,7 @@ function M.create(ctx)
|
||||
start_overlay = start_overlay,
|
||||
start_overlay_from_script_message = start_overlay_from_script_message,
|
||||
stop_overlay = stop_overlay,
|
||||
hide_visible_overlay = hide_visible_overlay,
|
||||
toggle_overlay = toggle_overlay,
|
||||
open_options = open_options,
|
||||
restart_overlay = restart_overlay,
|
||||
|
||||
163
scripts/build-yomitan.mjs
Normal file
163
scripts/build-yomitan.mjs
Normal 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();
|
||||
101
scripts/configure-plugin-binary-path.mjs
Normal file
101
scripts/configure-plugin-binary-path.mjs
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function normalizeCandidate(candidate) {
|
||||
if (typeof candidate !== 'string') return '';
|
||||
const trimmed = candidate.trim();
|
||||
return trimmed.length > 0 ? trimmed : '';
|
||||
}
|
||||
|
||||
function fileExists(candidate) {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function unique(values) {
|
||||
return Array.from(new Set(values.filter((value) => value.length > 0)));
|
||||
}
|
||||
|
||||
function findWindowsBinary(repoRoot) {
|
||||
const homeDir = process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || '';
|
||||
const appDataDir = process.env.APPDATA?.trim() || '';
|
||||
const derivedLocalAppData =
|
||||
appDataDir && /[\\/]Roaming$/i.test(appDataDir)
|
||||
? appDataDir.replace(/[\\/]Roaming$/i, `${path.sep}Local`)
|
||||
: '';
|
||||
const localAppData =
|
||||
process.env.LOCALAPPDATA?.trim() ||
|
||||
derivedLocalAppData ||
|
||||
(homeDir ? path.join(homeDir, 'AppData', 'Local') : '');
|
||||
const programFiles = process.env.ProgramFiles?.trim() || 'C:\\Program Files';
|
||||
const programFilesX86 = process.env['ProgramFiles(x86)']?.trim() || 'C:\\Program Files (x86)';
|
||||
|
||||
const candidates = unique([
|
||||
normalizeCandidate(process.env.SUBMINER_BINARY_PATH),
|
||||
normalizeCandidate(process.env.SUBMINER_APPIMAGE_PATH),
|
||||
localAppData ? path.join(localAppData, 'Programs', 'SubMiner', 'SubMiner.exe') : '',
|
||||
path.join(programFiles, 'SubMiner', 'SubMiner.exe'),
|
||||
path.join(programFilesX86, 'SubMiner', 'SubMiner.exe'),
|
||||
'C:\\SubMiner\\SubMiner.exe',
|
||||
path.join(repoRoot, 'release', 'win-unpacked', 'SubMiner.exe'),
|
||||
path.join(repoRoot, 'release', 'SubMiner', 'SubMiner.exe'),
|
||||
path.join(repoRoot, 'release', 'SubMiner.exe'),
|
||||
]);
|
||||
|
||||
return candidates.find((candidate) => fileExists(candidate)) || '';
|
||||
}
|
||||
|
||||
function rewriteBinaryPath(configPath, binaryPath) {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const normalizedPath = binaryPath.replace(/\r?\n/g, ' ').trim();
|
||||
const updated = content.replace(/^binary_path=.*$/m, `binary_path=${normalizedPath}`);
|
||||
if (updated !== content) {
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteSocketPath(configPath, socketPath) {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const normalizedPath = socketPath.replace(/\r?\n/g, ' ').trim();
|
||||
const updated = content.replace(/^socket_path=.*$/m, `socket_path=${normalizedPath}`);
|
||||
if (updated !== content) {
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
const [, , configPathArg, repoRootArg, platformArg] = process.argv;
|
||||
const configPath = normalizeCandidate(configPathArg);
|
||||
const repoRoot = normalizeCandidate(repoRootArg) || process.cwd();
|
||||
const platform = normalizeCandidate(platformArg) || process.platform;
|
||||
|
||||
if (!configPath) {
|
||||
console.error('[ERROR] Missing plugin config path');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fileExists(configPath)) {
|
||||
console.error(`[ERROR] Plugin config not found: ${configPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (platform !== 'win32') {
|
||||
console.log('[INFO] Skipping binary_path rewrite for non-Windows platform');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const windowsSocketPath = '\\\\.\\pipe\\subminer-socket';
|
||||
rewriteSocketPath(configPath, windowsSocketPath);
|
||||
|
||||
const binaryPath = findWindowsBinary(repoRoot);
|
||||
if (!binaryPath) {
|
||||
console.warn(
|
||||
`[WARN] Configured plugin socket_path=${windowsSocketPath} but could not detect SubMiner.exe; set binary_path manually or provide SUBMINER_BINARY_PATH`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
rewriteBinaryPath(configPath, binaryPath);
|
||||
console.log(`[INFO] Configured plugin socket_path=${windowsSocketPath} binary_path=${binaryPath}`);
|
||||
175
scripts/get-mpv-window-windows.ps1
Normal file
175
scripts/get-mpv-window-windows.ps1
Normal file
@@ -0,0 +1,175 @@
|
||||
param(
|
||||
[ValidateSet('geometry')]
|
||||
[string]$Mode = 'geometry',
|
||||
[string]$SocketPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
try {
|
||||
Add-Type -TypeDefinition @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class SubMinerWindowsHelper {
|
||||
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT {
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool IsWindowVisible(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool IsIconic(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
||||
|
||||
[DllImport("dwmapi.dll")]
|
||||
public static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute);
|
||||
}
|
||||
"@
|
||||
|
||||
$DWMWA_EXTENDED_FRAME_BOUNDS = 9
|
||||
|
||||
function Get-WindowBounds {
|
||||
param([IntPtr]$hWnd)
|
||||
|
||||
$rect = New-Object SubMinerWindowsHelper+RECT
|
||||
$size = [System.Runtime.InteropServices.Marshal]::SizeOf($rect)
|
||||
$dwmResult = [SubMinerWindowsHelper]::DwmGetWindowAttribute(
|
||||
$hWnd,
|
||||
$DWMWA_EXTENDED_FRAME_BOUNDS,
|
||||
[ref]$rect,
|
||||
$size
|
||||
)
|
||||
|
||||
if ($dwmResult -ne 0) {
|
||||
if (-not [SubMinerWindowsHelper]::GetWindowRect($hWnd, [ref]$rect)) {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
$width = $rect.Right - $rect.Left
|
||||
$height = $rect.Bottom - $rect.Top
|
||||
if ($width -le 0 -or $height -le 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
X = $rect.Left
|
||||
Y = $rect.Top
|
||||
Width = $width
|
||||
Height = $height
|
||||
Area = $width * $height
|
||||
}
|
||||
}
|
||||
|
||||
$commandLineByPid = @{}
|
||||
if (-not [string]::IsNullOrWhiteSpace($SocketPath)) {
|
||||
foreach ($process in Get-CimInstance Win32_Process) {
|
||||
$commandLineByPid[[uint32]$process.ProcessId] = $process.CommandLine
|
||||
}
|
||||
}
|
||||
|
||||
$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
|
||||
}
|
||||
84
scripts/prepare-build-assets.mjs
Normal file
84
scripts/prepare-build-assets.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
||||
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
||||
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
||||
const windowsHelperSourcePath = path.join(scriptDir, 'get-mpv-window-windows.ps1');
|
||||
const windowsHelperOutputPath = path.join(scriptsOutputDir, 'get-mpv-window-windows.ps1');
|
||||
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
||||
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
||||
const macosHelperSourceCopyPath = path.join(scriptsOutputDir, 'get-mpv-window-macos.swift');
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function copyFile(sourcePath, outputPath) {
|
||||
ensureDir(path.dirname(outputPath));
|
||||
fs.copyFileSync(sourcePath, outputPath);
|
||||
}
|
||||
|
||||
function copyRendererAssets() {
|
||||
copyFile(path.join(rendererSourceDir, 'index.html'), path.join(rendererOutputDir, 'index.html'));
|
||||
copyFile(path.join(rendererSourceDir, 'style.css'), path.join(rendererOutputDir, 'style.css'));
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(rendererOutputDir, 'fonts'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
process.stdout.write(`Staged renderer assets in ${rendererOutputDir}\n`);
|
||||
}
|
||||
|
||||
function stageWindowsHelper() {
|
||||
copyFile(windowsHelperSourcePath, windowsHelperOutputPath);
|
||||
process.stdout.write(`Staged Windows helper: ${windowsHelperOutputPath}\n`);
|
||||
}
|
||||
|
||||
function fallbackToMacosSource() {
|
||||
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
|
||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
||||
}
|
||||
|
||||
function shouldSkipMacosHelperBuild() {
|
||||
return process.env.SUBMINER_SKIP_MACOS_HELPER_BUILD === '1';
|
||||
}
|
||||
|
||||
function buildMacosHelper() {
|
||||
if (shouldSkipMacosHelperBuild()) {
|
||||
process.stdout.write('Skipping macOS helper build (SUBMINER_SKIP_MACOS_HELPER_BUILD=1)\n');
|
||||
fallbackToMacosSource();
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.stdout.write('Skipping macOS helper build (not on macOS)\n');
|
||||
fallbackToMacosSource();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('swiftc', ['-O', macosHelperSourcePath, '-o', macosHelperBinaryPath], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
fs.chmodSync(macosHelperBinaryPath, 0o755);
|
||||
process.stdout.write(`Built macOS helper: ${macosHelperBinaryPath}\n`);
|
||||
} catch (error) {
|
||||
process.stdout.write('Failed to compile macOS helper; using source fallback.\n');
|
||||
fallbackToMacosSource();
|
||||
if (error instanceof Error) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
copyRendererAssets();
|
||||
stageWindowsHelper();
|
||||
buildMacosHelper();
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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[@]}"
|
||||
|
||||
@@ -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': {
|
||||
|
||||
223
scripts/test-plugin-binary-windows.lua
Normal file
223
scripts/test-plugin-binary-windows.lua
Normal file
@@ -0,0 +1,223 @@
|
||||
local function assert_equal(actual, expected, message)
|
||||
if actual == expected then
|
||||
return
|
||||
end
|
||||
error((message or "assert_equal failed") .. "\nexpected: " .. tostring(expected) .. "\nactual: " .. tostring(actual))
|
||||
end
|
||||
|
||||
local function assert_true(condition, message)
|
||||
if condition then
|
||||
return
|
||||
end
|
||||
error(message or "assert_true failed")
|
||||
end
|
||||
|
||||
local function with_env(env, callback)
|
||||
local original_getenv = os.getenv
|
||||
os.getenv = function(name)
|
||||
local value = env[name]
|
||||
if value ~= nil then
|
||||
return value
|
||||
end
|
||||
return original_getenv(name)
|
||||
end
|
||||
|
||||
local ok, result = pcall(callback)
|
||||
os.getenv = original_getenv
|
||||
if not ok then
|
||||
error(result)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
local function create_binary_module(config)
|
||||
local binary_module = dofile("plugin/subminer/binary.lua")
|
||||
local entries = config.entries or {}
|
||||
|
||||
local binary = binary_module.create({
|
||||
mp = config.mp,
|
||||
utils = {
|
||||
file_info = function(path)
|
||||
local entry = entries[path]
|
||||
if entry == "file" then
|
||||
return { is_dir = false }
|
||||
end
|
||||
if entry == "dir" then
|
||||
return { is_dir = true }
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
join_path = function(...)
|
||||
return table.concat({ ... }, "\\")
|
||||
end,
|
||||
},
|
||||
opts = {
|
||||
binary_path = config.binary_path or "",
|
||||
},
|
||||
state = {},
|
||||
environment = {
|
||||
is_windows = function()
|
||||
return config.is_windows == true
|
||||
end,
|
||||
},
|
||||
log = {
|
||||
subminer_log = function() end,
|
||||
},
|
||||
})
|
||||
|
||||
return binary
|
||||
end
|
||||
|
||||
do
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
binary_path = "C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner",
|
||||
entries = {
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
|
||||
assert_equal(
|
||||
binary.find_binary(),
|
||||
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
|
||||
"windows resolver should append .exe for configured binary_path"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
mp = {
|
||||
command_native = function(command)
|
||||
local args = command.args or {}
|
||||
if args[1] == "powershell.exe" then
|
||||
return {
|
||||
status = 0,
|
||||
stdout = "C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe\n",
|
||||
stderr = "",
|
||||
}
|
||||
end
|
||||
return {
|
||||
status = 1,
|
||||
stdout = "",
|
||||
stderr = "unexpected command",
|
||||
}
|
||||
end,
|
||||
},
|
||||
entries = {
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
|
||||
assert_equal(
|
||||
binary.find_binary(),
|
||||
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
|
||||
"windows resolver should recover binary from running SubMiner process"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
binary_path = "C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner",
|
||||
entries = {
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner"] = "dir",
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
|
||||
assert_equal(
|
||||
binary.find_binary(),
|
||||
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
|
||||
"windows resolver should accept install directory binary_path"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local resolved = with_env({
|
||||
LOCALAPPDATA = "C:\\Users\\tester\\AppData\\Local",
|
||||
HOME = "",
|
||||
USERPROFILE = "C:\\Users\\tester",
|
||||
ProgramFiles = "C:\\Program Files",
|
||||
["ProgramFiles(x86)"] = "C:\\Program Files (x86)",
|
||||
}, function()
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
entries = {
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
return binary.find_binary()
|
||||
end)
|
||||
|
||||
assert_equal(
|
||||
resolved,
|
||||
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
|
||||
"windows auto-detection should probe LOCALAPPDATA install path"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local resolved = with_env({
|
||||
APPDATA = "C:\\Users\\tester\\AppData\\Roaming",
|
||||
LOCALAPPDATA = "",
|
||||
HOME = "",
|
||||
USERPROFILE = "C:\\Users\\tester",
|
||||
ProgramFiles = "C:\\Program Files",
|
||||
["ProgramFiles(x86)"] = "C:\\Program Files (x86)",
|
||||
}, function()
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
entries = {
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
return binary.find_binary()
|
||||
end)
|
||||
|
||||
assert_equal(
|
||||
resolved,
|
||||
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
|
||||
"windows auto-detection should derive Local install path from APPDATA"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local resolved = with_env({
|
||||
SUBMINER_BINARY_PATH = "C:\\Portable\\SubMiner\\SubMiner",
|
||||
}, function()
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
entries = {
|
||||
["C:\\Portable\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
return binary.find_binary()
|
||||
end)
|
||||
|
||||
assert_equal(
|
||||
resolved,
|
||||
"C:\\Portable\\SubMiner\\SubMiner.exe",
|
||||
"windows env override should resolve .exe suffix"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local binary = create_binary_module({
|
||||
is_windows = true,
|
||||
binary_path = "C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner",
|
||||
entries = {
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner"] = "dir",
|
||||
["C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe"] = "file",
|
||||
},
|
||||
})
|
||||
|
||||
assert_true(binary.ensure_binary_available() == true, "ensure_binary_available should cache discovered windows binary")
|
||||
assert_equal(
|
||||
binary.find_binary(),
|
||||
"C:\\Users\\tester\\AppData\\Local\\Programs\\SubMiner\\SubMiner.exe",
|
||||
"ensure_binary_available should not break follow-up lookup"
|
||||
)
|
||||
end
|
||||
|
||||
print("plugin windows binary resolver tests: OK")
|
||||
@@ -802,4 +802,29 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
platform = "windows",
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = "C:/Users/test/AppData/Local/Programs/SubMiner/SubMiner.exe",
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "\\\\.\\pipe\\subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
["C:/Users/test/AppData/Local/Programs/SubMiner/SubMiner.exe"] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for Windows legacy socket config scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
local start_call = find_start_call(recorded.async_calls)
|
||||
assert_true(
|
||||
start_call ~= nil,
|
||||
"Windows plugin should normalize legacy /tmp socket_path values to the named pipe default"
|
||||
)
|
||||
end
|
||||
|
||||
print("plugin start gate regression tests: OK")
|
||||
|
||||
@@ -47,6 +47,14 @@ test('parseArgs ignores missing value after --log-level', () => {
|
||||
assert.equal(args.start, true);
|
||||
});
|
||||
|
||||
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
|
||||
const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']);
|
||||
assert.equal(args.launchMpv, true);
|
||||
assert.deepEqual(args.launchMpvTargets, ['C:\\a.mkv', 'C:\\b.mkv']);
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), false);
|
||||
});
|
||||
|
||||
test('parseArgs handles jellyfin item listing controls', () => {
|
||||
const args = parseArgs([
|
||||
'--jellyfin-items',
|
||||
@@ -76,6 +84,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(hasExplicitCommand(stopOnly), true);
|
||||
assert.equal(shouldStartApp(stopOnly), false);
|
||||
|
||||
const launchMpv = parseArgs(['--launch-mpv']);
|
||||
assert.equal(launchMpv.launchMpv, true);
|
||||
assert.deepEqual(launchMpv.launchMpvTargets, []);
|
||||
assert.equal(hasExplicitCommand(launchMpv), true);
|
||||
assert.equal(shouldStartApp(launchMpv), false);
|
||||
|
||||
const toggle = parseArgs(['--toggle-visible-overlay']);
|
||||
assert.equal(hasExplicitCommand(toggle), true);
|
||||
assert.equal(shouldStartApp(toggle), true);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface CliArgs {
|
||||
background: boolean;
|
||||
start: boolean;
|
||||
launchMpv: boolean;
|
||||
launchMpvTargets: string[];
|
||||
stop: boolean;
|
||||
toggle: boolean;
|
||||
toggleVisibleOverlay: boolean;
|
||||
@@ -68,6 +70,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
background: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
@@ -123,6 +127,11 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
|
||||
if (arg === '--background') args.background = true;
|
||||
else if (arg === '--start') args.start = true;
|
||||
else if (arg === '--launch-mpv') {
|
||||
args.launchMpv = true;
|
||||
args.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--'));
|
||||
break;
|
||||
}
|
||||
else if (arg === '--stop') args.stop = true;
|
||||
else if (arg === '--toggle') args.toggle = true;
|
||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||
@@ -297,6 +306,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
return (
|
||||
args.background ||
|
||||
args.start ||
|
||||
args.launchMpv ||
|
||||
args.stop ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
@@ -342,6 +352,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
if (
|
||||
args.background ||
|
||||
args.start ||
|
||||
args.launchMpv ||
|
||||
args.toggle ||
|
||||
args.toggleVisibleOverlay ||
|
||||
args.settings ||
|
||||
@@ -361,6 +372,9 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.jellyfinPlay ||
|
||||
args.texthooker
|
||||
) {
|
||||
if (args.launchMpv) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -17,6 +17,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
|
||||
assert.match(output, /--help\s+Show this help/);
|
||||
assert.match(output, /default: 7777/);
|
||||
assert.match(output, /--launch-mpv/);
|
||||
assert.match(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
|
||||
@@ -12,6 +12,7 @@ ${B}Usage:${R} subminer ${D}[command] [options]${R}
|
||||
${B}Session${R}
|
||||
--background Start in tray/background mode
|
||||
--start Connect to mpv and launch overlay
|
||||
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
|
||||
--stop Stop the running instance
|
||||
--texthooker Start texthooker server only ${D}(no overlay)${R}
|
||||
|
||||
@@ -68,7 +69,7 @@ ${B}Jellyfin${R}
|
||||
|
||||
${B}Options${R}
|
||||
--socket ${D}PATH${R} mpv IPC socket path
|
||||
--backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos)${R}
|
||||
--backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos, windows)${R}
|
||||
--port ${D}PORT${R} Texthooker server port ${D}(default: ${defaultTexthookerPort})${R}
|
||||
--log-level ${D}LEVEL${R} ${D}debug | info | warn | error${R}
|
||||
--debug Enable debug mode ${D}(alias: --dev)${R}
|
||||
|
||||
@@ -10,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'));
|
||||
});
|
||||
|
||||
@@ -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]!);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export function generateConfigTemplate(
|
||||
lines.push(' *');
|
||||
lines.push(' * This file is auto-generated from src/config/definitions.ts.');
|
||||
lines.push(
|
||||
' * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.',
|
||||
' * Copy to %APPDATA%/SubMiner/config.jsonc on Windows, or $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) on Linux/macOS.',
|
||||
);
|
||||
lines.push(' */');
|
||||
lines.push('{');
|
||||
|
||||
@@ -7,6 +7,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
|
||||
@@ -7,6 +7,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -90,6 +90,9 @@ export function initializeOverlayRuntime(options: {
|
||||
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
};
|
||||
windowTracker.onTargetWindowFocusChange = () => {
|
||||
options.syncOverlayShortcuts();
|
||||
};
|
||||
windowTracker.onWindowFound = (geometry: WindowGeometry) => {
|
||||
options.updateVisibleOverlayBounds(geometry);
|
||||
if (options.isVisibleOverlayVisible()) {
|
||||
|
||||
@@ -21,8 +21,8 @@ function createMainWindowRecorder() {
|
||||
focus: () => {
|
||||
calls.push('focus');
|
||||
},
|
||||
setIgnoreMouseEvents: () => {
|
||||
calls.push('mouse-ignore');
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -122,6 +122,85 @@ test('non-macOS keeps fallback visible overlay behavior when tracker is not read
|
||||
assert.ok(!calls.includes('osd'));
|
||||
});
|
||||
|
||||
test('Windows visible overlay stays click-through and does not steal focus while tracked', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
test('Windows keeps visible overlay hidden while tracker is not ready', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => false,
|
||||
getGeometry: () => null,
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: true,
|
||||
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||
} as never);
|
||||
|
||||
assert.equal(trackerWarning, true);
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('update-bounds'));
|
||||
});
|
||||
|
||||
test('macOS keeps visible overlay hidden while tracker is not initialized yet', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
|
||||
@@ -14,6 +14,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
isMacOSPlatform?: boolean;
|
||||
isWindowsPlatform?: boolean;
|
||||
showOverlayLoadingOsd?: (message: string) => void;
|
||||
resolveFallbackBounds?: () => WindowGeometry;
|
||||
}): void {
|
||||
@@ -21,9 +22,24 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
const mainWindow = args.mainWindow;
|
||||
|
||||
const showPassiveVisibleOverlay = (): void => {
|
||||
if (args.isWindowsPlatform) {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
}
|
||||
args.ensureOverlayWindowLevel(mainWindow);
|
||||
mainWindow.show();
|
||||
if (!args.isWindowsPlatform) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
};
|
||||
|
||||
if (!args.visibleOverlayVisible) {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.mainWindow.hide();
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
@@ -35,31 +51,27 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
}
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
args.mainWindow.setIgnoreMouseEvents(false);
|
||||
args.ensureOverlayWindowLevel(args.mainWindow);
|
||||
args.mainWindow.show();
|
||||
args.mainWindow.focus();
|
||||
showPassiveVisibleOverlay();
|
||||
args.enforceOverlayLayerOrder();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.windowTracker) {
|
||||
if (args.isMacOSPlatform) {
|
||||
if (args.isMacOSPlatform || args.isWindowsPlatform) {
|
||||
if (!args.trackerNotReadyWarningShown) {
|
||||
args.setTrackerNotReadyWarningShown(true);
|
||||
args.showOverlayLoadingOsd?.('Overlay loading...');
|
||||
if (args.isMacOSPlatform) {
|
||||
args.showOverlayLoadingOsd?.('Overlay loading...');
|
||||
}
|
||||
}
|
||||
args.mainWindow.hide();
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
args.mainWindow.setIgnoreMouseEvents(false);
|
||||
args.ensureOverlayWindowLevel(args.mainWindow);
|
||||
args.mainWindow.show();
|
||||
args.mainWindow.focus();
|
||||
showPassiveVisibleOverlay();
|
||||
args.enforceOverlayLayerOrder();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
@@ -72,8 +84,8 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
}
|
||||
}
|
||||
|
||||
if (args.isMacOSPlatform) {
|
||||
args.mainWindow.hide();
|
||||
if (args.isMacOSPlatform || args.isWindowsPlatform) {
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
@@ -83,10 +95,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
|
||||
args.updateVisibleOverlayBounds(fallbackBounds);
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
args.mainWindow.setIgnoreMouseEvents(false);
|
||||
args.ensureOverlayWindowLevel(args.mainWindow);
|
||||
args.mainWindow.show();
|
||||
args.mainWindow.focus();
|
||||
showPassiveVisibleOverlay();
|
||||
args.enforceOverlayLayerOrder();
|
||||
args.syncOverlayShortcuts();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { WindowGeometry } from '../../types';
|
||||
import { createLogger } from '../../logger';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
|
||||
const { BrowserWindow: ElectronBrowserWindow } = electron;
|
||||
const logger = createLogger('main:overlay-window');
|
||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||
|
||||
@@ -20,7 +18,7 @@ function loadOverlayWindowLayer(window: BrowserWindow, layer: OverlayWindowKind)
|
||||
.loadFile(htmlPath, {
|
||||
query: { layer },
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
.catch((err) => {
|
||||
logger.error('Failed to load HTML file:', err);
|
||||
});
|
||||
}
|
||||
@@ -65,6 +63,11 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
|
||||
window.setFullScreenable(false);
|
||||
return;
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
window.moveTop();
|
||||
return;
|
||||
}
|
||||
window.setAlwaysOnTop(true);
|
||||
}
|
||||
|
||||
@@ -92,7 +95,8 @@ export function createOverlayWindow(
|
||||
onWindowClosed: (kind: OverlayWindowKind) => void;
|
||||
},
|
||||
): BrowserWindow {
|
||||
const window = new ElectronBrowserWindow({
|
||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
width: 800,
|
||||
height: 600,
|
||||
@@ -106,6 +110,7 @@ export function createOverlayWindow(
|
||||
hasShadow: false,
|
||||
focusable: true,
|
||||
acceptFirstMouse: true,
|
||||
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '..', '..', 'preload.js'),
|
||||
contextIsolation: true,
|
||||
@@ -162,6 +167,9 @@ export function createOverlayWindow(
|
||||
window.on('blur', () => {
|
||||
if (!window.isDestroyed()) {
|
||||
options.ensureOverlayWindowLevel(window);
|
||||
if (kind === 'visible' && window.isVisible()) {
|
||||
window.moveTop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
|
||||
@@ -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'), '');
|
||||
});
|
||||
|
||||
@@ -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
42
src/logger.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { resolveDefaultLogFilePath } from './logger';
|
||||
|
||||
test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
|
||||
const resolved = resolveDefaultLogFilePath({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
path.normalize(resolved),
|
||||
path.normalize(
|
||||
path.join(
|
||||
'C:\\Users\\tester\\AppData\\Roaming',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveDefaultLogFilePath uses .config on linux', () => {
|
||||
const resolved = resolveDefaultLogFilePath({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
resolved,
|
||||
path.join(
|
||||
'/home/tester',
|
||||
'.config',
|
||||
'SubMiner',
|
||||
'logs',
|
||||
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -116,8 +116,26 @@ function resolveLogFilePath(): string {
|
||||
if (envPath) {
|
||||
return envPath;
|
||||
}
|
||||
return resolveDefaultLogFilePath({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
appDataDir: process.env.APPDATA,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDefaultLogFilePath(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
appDataDir?: string;
|
||||
}): string {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
return path.join(os.homedir(), '.config', 'SubMiner', 'logs', `SubMiner-${date}.log`);
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const homeDir = options?.homeDir ?? os.homedir();
|
||||
const baseDir =
|
||||
platform === 'win32'
|
||||
? path.join(options?.appDataDir?.trim() || path.join(homeDir, 'AppData', 'Roaming'), 'SubMiner')
|
||||
: path.join(homeDir, '.config', 'SubMiner');
|
||||
return path.join(baseDir, 'logs', `SubMiner-${date}.log`);
|
||||
}
|
||||
|
||||
function appendToLogFile(line: string): void {
|
||||
|
||||
@@ -2,32 +2,53 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
normalizeStartupArgv,
|
||||
normalizeLaunchMpvTargets,
|
||||
sanitizeHelpEnv,
|
||||
sanitizeLaunchMpvEnv,
|
||||
sanitizeStartupEnv,
|
||||
sanitizeBackgroundEnv,
|
||||
shouldDetachBackgroundLaunch,
|
||||
shouldHandleHelpOnlyAtEntry,
|
||||
shouldHandleLaunchMpvAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
test('normalizeStartupArgv defaults no-arg startup to --start --background', () => {
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--start',
|
||||
'--background',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
normalizeStartupArgv(['SubMiner.AppImage', '--password-store', 'gnome-libsecret'], {}),
|
||||
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
|
||||
);
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--background'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--background',
|
||||
'--start',
|
||||
]);
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--help'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--help',
|
||||
]);
|
||||
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
||||
const originalPlatform = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
||||
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--start',
|
||||
'--background',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
normalizeStartupArgv(['SubMiner.AppImage', '--password-store', 'gnome-libsecret'], {}),
|
||||
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
|
||||
);
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--background'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--background',
|
||||
'--start',
|
||||
]);
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--help'], {}), [
|
||||
'SubMiner.AppImage',
|
||||
'--help',
|
||||
]);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeStartupArgv defaults no-arg Windows startup to --start only', () => {
|
||||
const originalPlatform = process.platform;
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
|
||||
assert.deepEqual(normalizeStartupArgv(['SubMiner.exe'], {}), ['SubMiner.exe', '--start']);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||
@@ -37,6 +58,18 @@ test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], { ELECTRON_RUN_AS_NODE: '1' }), false);
|
||||
});
|
||||
|
||||
test('launch-mpv entry helpers detect and normalize targets', () => {
|
||||
assert.equal(shouldHandleLaunchMpvAtEntry(['SubMiner.exe', '--launch-mpv'], {}), true);
|
||||
assert.equal(
|
||||
shouldHandleLaunchMpvAtEntry(['SubMiner.exe', '--launch-mpv'], { ELECTRON_RUN_AS_NODE: '1' }),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv']), []);
|
||||
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', 'C:\\a.mkv']), [
|
||||
'C:\\a.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sanitizeStartupEnv suppresses warnings and lsfg layer', () => {
|
||||
const env = sanitizeStartupEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
@@ -53,6 +86,14 @@ test('sanitizeHelpEnv suppresses warnings and lsfg layer', () => {
|
||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
||||
});
|
||||
|
||||
test('sanitizeLaunchMpvEnv suppresses warnings and lsfg layer', () => {
|
||||
const env = sanitizeLaunchMpvEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
});
|
||||
assert.equal(env.NODE_NO_WARNINGS, '1');
|
||||
assert.equal('VK_INSTANCE_LAYERS' in env, false);
|
||||
});
|
||||
|
||||
test('sanitizeBackgroundEnv marks background child and keeps warning suppression', () => {
|
||||
const env = sanitizeBackgroundEnv({
|
||||
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
|
||||
|
||||
@@ -45,6 +45,9 @@ export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): st
|
||||
|
||||
const effectiveArgs = removePassiveStartupArgs(argv.slice(1));
|
||||
if (effectiveArgs.length === 0) {
|
||||
if (process.platform === 'win32') {
|
||||
return [...argv, START_ARG];
|
||||
}
|
||||
return [...argv, START_ARG, BACKGROUND_ARG];
|
||||
}
|
||||
|
||||
@@ -72,6 +75,15 @@ export function shouldHandleHelpOnlyAtEntry(argv: string[], env: NodeJS.ProcessE
|
||||
return args.help && !shouldStartApp(args);
|
||||
}
|
||||
|
||||
export function shouldHandleLaunchMpvAtEntry(argv: string[], env: NodeJS.ProcessEnv): boolean {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||
return parseCliArgs(argv).launchMpv;
|
||||
}
|
||||
|
||||
export function normalizeLaunchMpvTargets(argv: string[]): string[] {
|
||||
return parseCliArgs(argv).launchMpvTargets;
|
||||
}
|
||||
|
||||
export function sanitizeStartupEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env = { ...baseEnv };
|
||||
if (!env.NODE_NO_WARNINGS) {
|
||||
@@ -85,6 +97,10 @@ export function sanitizeHelpEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return sanitizeStartupEnv(baseEnv);
|
||||
}
|
||||
|
||||
export function sanitizeLaunchMpvEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return sanitizeStartupEnv(baseEnv);
|
||||
}
|
||||
|
||||
export function sanitizeBackgroundEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const env = sanitizeStartupEnv(baseEnv);
|
||||
env[BACKGROUND_CHILD_ENV] = '1';
|
||||
|
||||
@@ -1,13 +1,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');
|
||||
}
|
||||
|
||||
194
src/main.ts
194
src/main.ts
@@ -99,6 +99,7 @@ import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||
import { RuntimeOptionsManager } from './runtime-options';
|
||||
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
|
||||
import { createLogger, setLogLevel, type LogLevelSource } from './logger';
|
||||
import { resolveDefaultLogFilePath } from './logger';
|
||||
import {
|
||||
commandNeedsOverlayRuntime,
|
||||
parseArgs,
|
||||
@@ -310,12 +311,17 @@ import {
|
||||
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
||||
createOpenFirstRunSetupWindowHandler,
|
||||
parseFirstRunSetupSubmissionUrl,
|
||||
type FirstRunSetupAction,
|
||||
type FirstRunSetupSubmission,
|
||||
} from './main/runtime/first-run-setup-window';
|
||||
import {
|
||||
detectInstalledFirstRunPlugin,
|
||||
installFirstRunPluginToDefaultLocation,
|
||||
} from './main/runtime/first-run-setup-plugin';
|
||||
import {
|
||||
applyWindowsMpvShortcuts,
|
||||
detectWindowsMpvShortcuts,
|
||||
resolveWindowsMpvShortcutPaths,
|
||||
} from './main/runtime/windows-mpv-shortcuts';
|
||||
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
|
||||
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
|
||||
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
|
||||
@@ -344,6 +350,10 @@ import {
|
||||
} from './main/runtime/composers';
|
||||
import { createStartupBootstrapRuntimeDeps } from './main/startup';
|
||||
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
|
||||
import {
|
||||
registerSecondInstanceHandlerEarly,
|
||||
requestSingleInstanceLockEarly,
|
||||
} from './main/early-single-instance';
|
||||
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||
@@ -362,6 +372,10 @@ import { createMediaRuntimeService } from './main/media-runtime';
|
||||
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
|
||||
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
|
||||
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
} from './main/runtime/yomitan-anki-server';
|
||||
import {
|
||||
type AnilistMediaGuessRuntimeState,
|
||||
type StartupState,
|
||||
@@ -401,7 +415,11 @@ if (process.platform === 'linux') {
|
||||
app.setName('SubMiner');
|
||||
|
||||
const DEFAULT_TEXTHOOKER_PORT = 5174;
|
||||
const DEFAULT_MPV_LOG_FILE = path.join(os.homedir(), '.cache', 'SubMiner', 'mp.log');
|
||||
const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
appDataDir: process.env.APPDATA,
|
||||
});
|
||||
const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize';
|
||||
const ANILIST_SETUP_RESPONSE_TYPE = 'token';
|
||||
const ANILIST_DEFAULT_CLIENT_ID = '36084';
|
||||
@@ -462,6 +480,8 @@ function applyJellyfinMpvDefaults(
|
||||
}
|
||||
|
||||
const CONFIG_DIR = resolveConfigDir({
|
||||
platform: process.platform,
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
@@ -480,7 +500,7 @@ const configService = (() => {
|
||||
{
|
||||
logError: (details) => console.error(details),
|
||||
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
|
||||
quit: () => app.quit(),
|
||||
quit: () => requestAppQuit(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -552,6 +572,22 @@ const appLogger = {
|
||||
},
|
||||
};
|
||||
const runtimeRegistry = createMainRuntimeRegistry();
|
||||
const appLifecycleApp = {
|
||||
requestSingleInstanceLock: () => requestSingleInstanceLockEarly(app),
|
||||
quit: () => app.quit(),
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => {
|
||||
if (event === 'second-instance') {
|
||||
registerSecondInstanceHandlerEarly(
|
||||
app,
|
||||
listener as (_event: unknown, argv: string[]) => void,
|
||||
);
|
||||
return app;
|
||||
}
|
||||
app.on(event as Parameters<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,
|
||||
|
||||
56
src/main/early-single-instance.test.ts
Normal file
56
src/main/early-single-instance.test.ts
Normal 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'],
|
||||
]);
|
||||
});
|
||||
54
src/main/early-single-instance.ts
Normal file
54
src/main/early-single-instance.ts
Normal 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();
|
||||
}
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
import {
|
||||
refreshOverlayShortcutsRuntime,
|
||||
registerOverlayShortcuts,
|
||||
shouldActivateOverlayShortcuts,
|
||||
syncOverlayShortcutsRuntime,
|
||||
unregisterOverlayShortcutsRuntime,
|
||||
} from '../core/services/overlay-shortcut';
|
||||
} from '../core/services';
|
||||
import { runOverlayShortcutLocalFallback } from '../core/services/overlay-shortcut-handler';
|
||||
|
||||
export interface OverlayShortcutRuntimeServiceInput {
|
||||
@@ -17,8 +16,7 @@ export interface OverlayShortcutRuntimeServiceInput {
|
||||
getShortcutsRegistered: () => boolean;
|
||||
setShortcutsRegistered: (registered: boolean) => void;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
isMacOSPlatform: () => boolean;
|
||||
isTrackedMpvWindowFocused: () => boolean;
|
||||
isOverlayShortcutContextActive?: () => boolean;
|
||||
showMpvOsd: (text: string) => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJimaku: () => void;
|
||||
@@ -93,11 +91,7 @@ export function createOverlayShortcutsRuntimeService(
|
||||
};
|
||||
|
||||
const shouldOverlayShortcutsBeActive = () =>
|
||||
shouldActivateOverlayShortcuts({
|
||||
overlayRuntimeInitialized: input.isOverlayRuntimeInitialized(),
|
||||
isMacOSPlatform: input.isMacOSPlatform(),
|
||||
trackedMpvWindowFocused: input.isTrackedMpvWindowFocused(),
|
||||
});
|
||||
input.isOverlayRuntimeInitialized() && (input.isOverlayShortcutContextActive?.() ?? true);
|
||||
|
||||
return {
|
||||
tryHandleOverlayShortcutLocalFallback: (inputEvent) =>
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
enforceOverlayLayerOrder: () => void;
|
||||
syncOverlayShortcuts: () => void;
|
||||
isMacOSPlatform: () => boolean;
|
||||
isWindowsPlatform: () => boolean;
|
||||
showOverlayLoadingOsd: (message: string) => void;
|
||||
resolveFallbackBounds: () => WindowGeometry;
|
||||
}
|
||||
@@ -45,6 +46,7 @@ export function createOverlayVisibilityRuntimeService(
|
||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||
isMacOSPlatform: deps.isMacOSPlatform(),
|
||||
isWindowsPlatform: deps.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
});
|
||||
|
||||
@@ -29,12 +29,16 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
||||
destroyJellyfinSetupWindow: () => calls.push('destroy-jellyfin-window'),
|
||||
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
|
||||
destroyFirstRunSetupWindow: () => calls.push('destroy-first-run-window'),
|
||||
clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'),
|
||||
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 22);
|
||||
assert.equal(calls.length, 26);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
|
||||
@@ -19,6 +19,10 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
clearAnilistSetupWindow: () => void;
|
||||
destroyJellyfinSetupWindow: () => void;
|
||||
clearJellyfinSetupWindow: () => void;
|
||||
destroyFirstRunSetupWindow: () => void;
|
||||
clearFirstRunSetupWindow: () => void;
|
||||
destroyYomitanSettingsWindow: () => void;
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
@@ -43,6 +47,10 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.clearAnilistSetupWindow();
|
||||
deps.destroyJellyfinSetupWindow();
|
||||
deps.clearJellyfinSetupWindow();
|
||||
deps.destroyFirstRunSetupWindow();
|
||||
deps.clearFirstRunSetupWindow();
|
||||
deps.destroyYomitanSettingsWindow();
|
||||
deps.clearYomitanSettingsWindow();
|
||||
deps.stopJellyfinRemoteSession();
|
||||
deps.stopDiscordPresenceService();
|
||||
};
|
||||
|
||||
@@ -46,6 +46,12 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
clearAnilistSetupWindow: () => calls.push('clear-anilist-window'),
|
||||
getJellyfinSetupWindow: () => ({ destroy: () => calls.push('destroy-jellyfin-window') }),
|
||||
clearJellyfinSetupWindow: () => calls.push('clear-jellyfin-window'),
|
||||
getFirstRunSetupWindow: () => ({ destroy: () => calls.push('destroy-first-run-window') }),
|
||||
clearFirstRunSetupWindow: () => calls.push('clear-first-run-window'),
|
||||
getYomitanSettingsWindow: () => ({
|
||||
destroy: () => calls.push('destroy-yomitan-settings-window'),
|
||||
}),
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
@@ -61,6 +67,8 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
assert.ok(calls.includes('clear-reconnect-ref'));
|
||||
assert.ok(calls.includes('destroy-immersion'));
|
||||
assert.ok(calls.includes('clear-immersion-ref'));
|
||||
assert.ok(calls.includes('destroy-first-run-window'));
|
||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.equal(reconnectTimer, null);
|
||||
@@ -95,6 +103,10 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
clearAnilistSetupWindow: () => {},
|
||||
getJellyfinSetupWindow: () => null,
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
getFirstRunSetupWindow: () => null,
|
||||
clearFirstRunSetupWindow: () => {},
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
@@ -44,6 +44,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearAnilistSetupWindow: () => void;
|
||||
getJellyfinSetupWindow: () => Destroyable | null;
|
||||
clearJellyfinSetupWindow: () => void;
|
||||
getFirstRunSetupWindow: () => Destroyable | null;
|
||||
clearFirstRunSetupWindow: () => void;
|
||||
getYomitanSettingsWindow: () => Destroyable | null;
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
@@ -98,6 +102,14 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
deps.getJellyfinSetupWindow()?.destroy();
|
||||
},
|
||||
clearJellyfinSetupWindow: () => deps.clearJellyfinSetupWindow(),
|
||||
destroyFirstRunSetupWindow: () => {
|
||||
deps.getFirstRunSetupWindow()?.destroy();
|
||||
},
|
||||
clearFirstRunSetupWindow: () => deps.clearFirstRunSetupWindow(),
|
||||
destroyYomitanSettingsWindow: () => {
|
||||
deps.getYomitanSettingsWindow()?.destroy();
|
||||
},
|
||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
|
||||
@@ -35,6 +35,10 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
clearAnilistSetupWindow: () => {},
|
||||
getJellyfinSetupWindow: () => null,
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
getFirstRunSetupWindow: () => null,
|
||||
clearFirstRunSetupWindow: () => {},
|
||||
getYomitanSettingsWindow: () => null,
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
test('dictionary roots main handler returns expected root list', () => {
|
||||
const roots = createBuildDictionaryRootsMainHandler({
|
||||
platform: 'darwin',
|
||||
dirname: '/repo/dist/main',
|
||||
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
|
||||
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
|
||||
@@ -44,6 +45,7 @@ test('jlpt dictionary runtime main deps builder maps search paths and log prefix
|
||||
|
||||
test('frequency dictionary roots main handler returns expected root list', () => {
|
||||
const roots = createBuildFrequencyDictionaryRootsMainHandler({
|
||||
platform: 'darwin',
|
||||
dirname: '/repo/dist/main',
|
||||
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
|
||||
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
|
||||
@@ -59,6 +61,42 @@ test('frequency dictionary roots main handler returns expected root list', () =>
|
||||
assert.equal(roots[10], '/repo');
|
||||
});
|
||||
|
||||
test('dictionary roots main handler uses APPDATA-style roots on windows', () => {
|
||||
const roots = createBuildDictionaryRootsMainHandler({
|
||||
platform: 'win32',
|
||||
dirname: 'C:\\repo\\dist\\main',
|
||||
appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar',
|
||||
resourcesPath: 'C:\\Program Files\\SubMiner\\resources',
|
||||
userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
|
||||
appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
|
||||
homeDir: 'C:\\Users\\a',
|
||||
appDataDir: 'C:\\Users\\a\\AppData\\Roaming',
|
||||
cwd: 'C:\\repo',
|
||||
joinPath: (...parts) => parts.join('\\'),
|
||||
})();
|
||||
|
||||
assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false);
|
||||
assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true);
|
||||
});
|
||||
|
||||
test('frequency dictionary roots main handler uses APPDATA-style roots on windows', () => {
|
||||
const roots = createBuildFrequencyDictionaryRootsMainHandler({
|
||||
platform: 'win32',
|
||||
dirname: 'C:\\repo\\dist\\main',
|
||||
appPath: 'C:\\Program Files\\SubMiner\\resources\\app.asar',
|
||||
resourcesPath: 'C:\\Program Files\\SubMiner\\resources',
|
||||
userDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
|
||||
appUserDataPath: 'C:\\Users\\a\\AppData\\Roaming\\SubMiner',
|
||||
homeDir: 'C:\\Users\\a',
|
||||
appDataDir: 'C:\\Users\\a\\AppData\\Roaming',
|
||||
cwd: 'C:\\repo',
|
||||
joinPath: (...parts) => parts.join('\\'),
|
||||
})();
|
||||
|
||||
assert.equal(roots.includes('C:\\Users\\a\\.config\\SubMiner'), false);
|
||||
assert.equal(roots.includes('C:\\Users\\a\\AppData\\Roaming\\SubMiner'), true);
|
||||
});
|
||||
|
||||
test('frequency dictionary runtime main deps builder maps search paths/source and log prefix', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildFrequencyDictionaryRuntimeMainDepsHandler({
|
||||
|
||||
@@ -3,53 +3,93 @@ import type { FrequencyDictionaryLookup, JlptLevel } from '../../types';
|
||||
type JlptLookup = (term: string) => JlptLevel | null;
|
||||
|
||||
export function createBuildDictionaryRootsMainHandler(deps: {
|
||||
platform: NodeJS.Platform;
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
userDataPath: string;
|
||||
appUserDataPath: string;
|
||||
homeDir: string;
|
||||
appDataDir?: string;
|
||||
cwd: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
}) {
|
||||
return () => [
|
||||
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'),
|
||||
deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'),
|
||||
deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'),
|
||||
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'),
|
||||
deps.userDataPath,
|
||||
deps.appUserDataPath,
|
||||
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, '.config', 'subminer'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
|
||||
deps.cwd,
|
||||
];
|
||||
return () => {
|
||||
const platformRoots =
|
||||
deps.platform === 'win32'
|
||||
? [
|
||||
deps.joinPath(
|
||||
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
|
||||
'SubMiner',
|
||||
),
|
||||
deps.joinPath(
|
||||
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
|
||||
'subminer',
|
||||
),
|
||||
]
|
||||
: [
|
||||
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, '.config', 'subminer'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
|
||||
];
|
||||
|
||||
return [
|
||||
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'yomitan-jlpt-vocab'),
|
||||
deps.joinPath(deps.appPath, 'vendor', 'yomitan-jlpt-vocab'),
|
||||
deps.joinPath(deps.resourcesPath, 'yomitan-jlpt-vocab'),
|
||||
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'yomitan-jlpt-vocab'),
|
||||
deps.userDataPath,
|
||||
deps.appUserDataPath,
|
||||
...platformRoots,
|
||||
deps.cwd,
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
export function createBuildFrequencyDictionaryRootsMainHandler(deps: {
|
||||
platform: NodeJS.Platform;
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
userDataPath: string;
|
||||
appUserDataPath: string;
|
||||
homeDir: string;
|
||||
appDataDir?: string;
|
||||
cwd: string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
}) {
|
||||
return () => [
|
||||
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'),
|
||||
deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'),
|
||||
deps.joinPath(deps.resourcesPath, 'frequency-dictionary'),
|
||||
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'),
|
||||
deps.userDataPath,
|
||||
deps.appUserDataPath,
|
||||
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, '.config', 'subminer'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
|
||||
deps.cwd,
|
||||
];
|
||||
return () => {
|
||||
const platformRoots =
|
||||
deps.platform === 'win32'
|
||||
? [
|
||||
deps.joinPath(
|
||||
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
|
||||
'SubMiner',
|
||||
),
|
||||
deps.joinPath(
|
||||
deps.appDataDir ?? deps.joinPath(deps.homeDir, 'AppData', 'Roaming'),
|
||||
'subminer',
|
||||
),
|
||||
]
|
||||
: [
|
||||
deps.joinPath(deps.homeDir, '.config', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, '.config', 'subminer'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'SubMiner'),
|
||||
deps.joinPath(deps.homeDir, 'Library', 'Application Support', 'subminer'),
|
||||
];
|
||||
|
||||
return [
|
||||
deps.joinPath(deps.dirname, '..', '..', 'vendor', 'frequency-dictionary'),
|
||||
deps.joinPath(deps.appPath, 'vendor', 'frequency-dictionary'),
|
||||
deps.joinPath(deps.resourcesPath, 'frequency-dictionary'),
|
||||
deps.joinPath(deps.resourcesPath, 'app.asar', 'vendor', 'frequency-dictionary'),
|
||||
deps.userDataPath,
|
||||
deps.appUserDataPath,
|
||||
...platformRoots,
|
||||
deps.cwd,
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
export function createBuildJlptDictionaryRuntimeMainDepsHandler(deps: {
|
||||
|
||||
@@ -54,8 +54,10 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
|
||||
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginEntrypointPath), { recursive: true });
|
||||
fs.mkdirSync(installPaths.pluginDir, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
||||
fs.writeFileSync(path.join(installPaths.scriptsDir, 'subminer-loader.lua'), '-- old loader');
|
||||
fs.writeFileSync(path.join(installPaths.pluginDir, 'old.lua'), '-- old plugin');
|
||||
fs.writeFileSync(installPaths.pluginConfigPath, 'old=true\n');
|
||||
|
||||
@@ -72,7 +74,7 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
|
||||
assert.equal(result.pluginInstallStatus, 'installed');
|
||||
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(installPaths.pluginDir, 'main.lua'), 'utf8'),
|
||||
fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'),
|
||||
'-- packaged plugin',
|
||||
);
|
||||
assert.equal(fs.readFileSync(installPaths.pluginConfigPath, 'utf8'), 'configured=true\n');
|
||||
@@ -83,6 +85,10 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
|
||||
scriptsDirEntries.some((entry) => entry.startsWith('subminer.bak.')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
scriptsDirEntries.some((entry) => entry.startsWith('subminer-loader.lua.bak.')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
scriptOptsEntries.some((entry) => entry.startsWith('subminer.conf.bak.')),
|
||||
true,
|
||||
@@ -90,17 +96,71 @@ test('installFirstRunPluginToDefaultLocation installs plugin and backs up existi
|
||||
});
|
||||
});
|
||||
|
||||
test('installFirstRunPluginToDefaultLocation reports unsupported platforms', () => {
|
||||
const result = installFirstRunPluginToDefaultLocation({
|
||||
platform: 'win32',
|
||||
homeDir: '/tmp/home',
|
||||
xdgConfigHome: '/tmp/xdg',
|
||||
dirname: '/tmp/dist/main/runtime',
|
||||
appPath: '/tmp/app',
|
||||
resourcesPath: '/tmp/resources',
|
||||
});
|
||||
test('installFirstRunPluginToDefaultLocation installs plugin to Windows mpv defaults', () => {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
withTempDir((root) => {
|
||||
const resourcesPath = path.join(root, 'resources');
|
||||
const pluginRoot = path.join(resourcesPath, 'plugin');
|
||||
const homeDir = path.join(root, 'home');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.pluginInstallStatus, 'failed');
|
||||
assert.match(result.message, /not supported/i);
|
||||
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer.conf'), 'configured=true\n');
|
||||
|
||||
const result = installFirstRunPluginToDefaultLocation({
|
||||
platform: 'win32',
|
||||
homeDir,
|
||||
dirname: path.join(root, 'dist', 'main', 'runtime'),
|
||||
appPath: path.join(root, 'app'),
|
||||
resourcesPath,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.pluginInstallStatus, 'installed');
|
||||
assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginEntrypointPath, 'utf8'),
|
||||
'-- packaged plugin',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'configured=true\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('installFirstRunPluginToDefaultLocation rewrites Windows plugin socket_path', () => {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
withTempDir((root) => {
|
||||
const resourcesPath = path.join(root, 'resources');
|
||||
const pluginRoot = path.join(resourcesPath, 'plugin');
|
||||
const homeDir = path.join(root, 'home');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('win32', homeDir);
|
||||
|
||||
fs.mkdirSync(path.join(pluginRoot, 'subminer'), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginRoot, 'subminer', 'main.lua'), '-- packaged plugin');
|
||||
fs.writeFileSync(
|
||||
path.join(pluginRoot, 'subminer.conf'),
|
||||
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
|
||||
const result = installFirstRunPluginToDefaultLocation({
|
||||
platform: 'win32',
|
||||
homeDir,
|
||||
dirname: path.join(root, 'dist', 'main', 'runtime'),
|
||||
appPath: path.join(root, 'app'),
|
||||
resourcesPath,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'binary_path=\nsocket_path=\\\\.\\pipe\\subminer-socket\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,25 @@ function backupExistingPath(targetPath: string): void {
|
||||
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
|
||||
}
|
||||
|
||||
function resolveLegacyPluginLoaderPath(installPaths: MpvInstallPaths): string {
|
||||
return path.join(installPaths.scriptsDir, 'subminer.lua');
|
||||
}
|
||||
|
||||
function resolveLegacyPluginDebugLoaderPath(installPaths: MpvInstallPaths): string {
|
||||
return path.join(installPaths.scriptsDir, 'subminer-loader.lua');
|
||||
}
|
||||
|
||||
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const updated = content.replace(
|
||||
/^socket_path=.*$/m,
|
||||
'socket_path=\\\\.\\pipe\\subminer-socket',
|
||||
);
|
||||
if (updated !== content) {
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackagedFirstRunPluginAssets(deps: {
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
@@ -32,7 +51,11 @@ export function resolvePackagedFirstRunPluginAssets(deps: {
|
||||
for (const root of roots) {
|
||||
const pluginDirSource = joinPath(root, 'subminer');
|
||||
const pluginConfigSource = joinPath(root, 'subminer.conf');
|
||||
if (existsSync(pluginDirSource) && existsSync(pluginConfigSource)) {
|
||||
if (
|
||||
existsSync(pluginDirSource) &&
|
||||
existsSync(pluginConfigSource) &&
|
||||
existsSync(joinPath(pluginDirSource, 'main.lua'))
|
||||
) {
|
||||
return { pluginDirSource, pluginConfigSource };
|
||||
}
|
||||
}
|
||||
@@ -45,7 +68,11 @@ export function detectInstalledFirstRunPlugin(
|
||||
deps?: { existsSync?: (candidate: string) => boolean },
|
||||
): boolean {
|
||||
const existsSync = deps?.existsSync ?? fs.existsSync;
|
||||
return existsSync(installPaths.pluginDir) && existsSync(installPaths.pluginConfigPath);
|
||||
return (
|
||||
existsSync(installPaths.pluginEntrypointPath) &&
|
||||
existsSync(installPaths.pluginDir) &&
|
||||
existsSync(installPaths.pluginConfigPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function installFirstRunPluginToDefaultLocation(options: {
|
||||
@@ -86,10 +113,15 @@ export function installFirstRunPluginToDefaultLocation(options: {
|
||||
|
||||
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
|
||||
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
|
||||
backupExistingPath(resolveLegacyPluginLoaderPath(installPaths));
|
||||
backupExistingPath(resolveLegacyPluginDebugLoaderPath(installPaths));
|
||||
backupExistingPath(installPaths.pluginDir);
|
||||
backupExistingPath(installPaths.pluginConfigPath);
|
||||
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
|
||||
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
|
||||
if (options.platform === 'win32') {
|
||||
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -21,6 +21,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
background: false,
|
||||
start: false,
|
||||
launchMpv: false,
|
||||
launchMpvTargets: [],
|
||||
stop: false,
|
||||
toggle: false,
|
||||
toggleVisibleOverlay: false,
|
||||
@@ -169,3 +171,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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildFirstRunSetupHtml,
|
||||
createHandleFirstRunSetupNavigationHandler,
|
||||
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
||||
createOpenFirstRunSetupWindowHandler,
|
||||
parseFirstRunSetupSubmissionUrl,
|
||||
} from './first-run-setup-window';
|
||||
|
||||
@@ -14,6 +15,14 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
||||
canFinish: false,
|
||||
pluginStatus: 'optional',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
message: 'Waiting for dictionaries',
|
||||
});
|
||||
|
||||
@@ -31,6 +40,14 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
||||
canFinish: true,
|
||||
pluginStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
windowsMpvShortcuts: {
|
||||
supported: true,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: true,
|
||||
desktopInstalled: false,
|
||||
status: 'installed',
|
||||
},
|
||||
message: null,
|
||||
});
|
||||
|
||||
@@ -60,8 +77,8 @@ test('first-run setup navigation handler prevents default and dispatches action'
|
||||
const calls: string[] = [];
|
||||
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
|
||||
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
|
||||
handleAction: async (action) => {
|
||||
calls.push(action);
|
||||
handleAction: async (submission) => {
|
||||
calls.push(submission.action);
|
||||
},
|
||||
logError: (message) => calls.push(message),
|
||||
});
|
||||
@@ -75,3 +92,71 @@ test('first-run setup navigation handler prevents default and dispatches action'
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, ['preventDefault', 'install-plugin']);
|
||||
});
|
||||
|
||||
test('closing incomplete first-run setup quits app outside background mode', async () => {
|
||||
const calls: string[] = [];
|
||||
let closedHandler: (() => void) | undefined;
|
||||
const handler = createOpenFirstRunSetupWindowHandler({
|
||||
maybeFocusExistingSetupWindow: () => false,
|
||||
createSetupWindow: () =>
|
||||
({
|
||||
webContents: {
|
||||
on: () => {},
|
||||
},
|
||||
loadURL: async () => undefined,
|
||||
on: (event: 'closed', callback: () => void) => {
|
||||
if (event === 'closed') {
|
||||
closedHandler = callback;
|
||||
}
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
close: () => calls.push('close-window'),
|
||||
focus: () => {},
|
||||
}) as never,
|
||||
getSetupSnapshot: async () => ({
|
||||
configReady: false,
|
||||
dictionaryCount: 0,
|
||||
canFinish: false,
|
||||
pluginStatus: 'optional',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcuts: {
|
||||
supported: false,
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: true,
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: false,
|
||||
status: 'optional',
|
||||
},
|
||||
message: null,
|
||||
}),
|
||||
buildSetupHtml: () => '<html></html>',
|
||||
parseSubmissionUrl: () => null,
|
||||
handleAction: async () => undefined,
|
||||
markSetupInProgress: async () => undefined,
|
||||
markSetupCancelled: async () => {
|
||||
calls.push('cancelled');
|
||||
},
|
||||
isSetupCompleted: () => false,
|
||||
shouldQuitWhenClosedIncomplete: () => true,
|
||||
quitApp: () => {
|
||||
calls.push('quit');
|
||||
},
|
||||
clearSetupWindow: () => {
|
||||
calls.push('clear');
|
||||
},
|
||||
setSetupWindow: () => {
|
||||
calls.push('set');
|
||||
},
|
||||
encodeURIComponent: (value) => value,
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
handler();
|
||||
if (typeof closedHandler !== 'function') {
|
||||
throw new Error('expected closed handler');
|
||||
}
|
||||
closedHandler();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(calls, ['set', 'cancelled', 'clear', 'quit']);
|
||||
});
|
||||
|
||||
@@ -16,17 +16,32 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
|
||||
|
||||
export type FirstRunSetupAction =
|
||||
| 'install-plugin'
|
||||
| 'configure-windows-mpv-shortcuts'
|
||||
| 'open-yomitan-settings'
|
||||
| 'refresh'
|
||||
| 'skip-plugin'
|
||||
| 'finish';
|
||||
|
||||
export interface FirstRunSetupSubmission {
|
||||
action: FirstRunSetupAction;
|
||||
startMenuEnabled?: boolean;
|
||||
desktopEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FirstRunSetupHtmlModel {
|
||||
configReady: boolean;
|
||||
dictionaryCount: number;
|
||||
canFinish: boolean;
|
||||
pluginStatus: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||
pluginInstallPathSummary: string | null;
|
||||
windowsMpvShortcuts: {
|
||||
supported: boolean;
|
||||
startMenuEnabled: boolean;
|
||||
desktopEnabled: boolean;
|
||||
startMenuInstalled: boolean;
|
||||
desktopInstalled: boolean;
|
||||
status: 'installed' | 'optional' | 'skipped' | 'failed';
|
||||
};
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
@@ -61,6 +76,43 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
: model.pluginStatus === 'skipped'
|
||||
? 'muted'
|
||||
: 'warn';
|
||||
const windowsShortcutLabel =
|
||||
model.windowsMpvShortcuts.status === 'installed'
|
||||
? 'Installed'
|
||||
: model.windowsMpvShortcuts.status === 'skipped'
|
||||
? 'Skipped'
|
||||
: model.windowsMpvShortcuts.status === 'failed'
|
||||
? 'Failed'
|
||||
: 'Optional';
|
||||
const windowsShortcutTone =
|
||||
model.windowsMpvShortcuts.status === 'installed'
|
||||
? 'ready'
|
||||
: model.windowsMpvShortcuts.status === 'failed'
|
||||
? 'danger'
|
||||
: model.windowsMpvShortcuts.status === 'skipped'
|
||||
? 'muted'
|
||||
: 'warn';
|
||||
const windowsShortcutCard = model.windowsMpvShortcuts.supported
|
||||
? `
|
||||
<div class="card block">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<strong>Windows mpv launcher</strong>
|
||||
<div class="meta">Create standalone \`SubMiner mpv\` shortcuts that run \`SubMiner.exe --launch-mpv\`.</div>
|
||||
<div class="meta">Installed: Start Menu ${model.windowsMpvShortcuts.startMenuInstalled ? 'yes' : 'no'}, Desktop ${model.windowsMpvShortcuts.desktopInstalled ? 'yes' : 'no'}</div>
|
||||
</div>
|
||||
${renderStatusBadge(windowsShortcutLabel, windowsShortcutTone)}
|
||||
</div>
|
||||
<form
|
||||
class="shortcut-form"
|
||||
onsubmit="event.preventDefault(); const params = new URLSearchParams({ action: 'configure-windows-mpv-shortcuts', startMenu: document.getElementById('shortcut-start-menu').checked ? '1' : '0', desktop: document.getElementById('shortcut-desktop').checked ? '1' : '0' }); window.location.href = 'subminer://first-run-setup?' + params.toString();"
|
||||
>
|
||||
<label><input id="shortcut-start-menu" type="checkbox" ${model.windowsMpvShortcuts.startMenuEnabled ? 'checked' : ''} /> Create Start Menu shortcut</label>
|
||||
<label><input id="shortcut-desktop" type="checkbox" ${model.windowsMpvShortcuts.desktopEnabled ? 'checked' : ''} /> Create Desktop shortcut</label>
|
||||
<button type="submit">Apply mpv launcher shortcuts</button>
|
||||
</form>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
@@ -109,10 +161,30 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.card.block {
|
||||
display: block;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.shortcut-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
label {
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -192,6 +264,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
model.dictionaryCount >= 1 ? 'ready' : 'warn',
|
||||
)}
|
||||
</div>
|
||||
${windowsShortcutCard}
|
||||
<div class="actions">
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
|
||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
||||
@@ -208,7 +281,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
||||
|
||||
export function parseFirstRunSetupSubmissionUrl(
|
||||
rawUrl: string,
|
||||
): { action: FirstRunSetupAction } | null {
|
||||
): FirstRunSetupSubmission | null {
|
||||
if (!rawUrl.startsWith('subminer://first-run-setup')) {
|
||||
return null;
|
||||
}
|
||||
@@ -216,6 +289,7 @@ export function parseFirstRunSetupSubmissionUrl(
|
||||
const action = parsed.searchParams.get('action');
|
||||
if (
|
||||
action !== 'install-plugin' &&
|
||||
action !== 'configure-windows-mpv-shortcuts' &&
|
||||
action !== 'open-yomitan-settings' &&
|
||||
action !== 'refresh' &&
|
||||
action !== 'skip-plugin' &&
|
||||
@@ -223,6 +297,13 @@ export function parseFirstRunSetupSubmissionUrl(
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (action === 'configure-windows-mpv-shortcuts') {
|
||||
return {
|
||||
action,
|
||||
startMenuEnabled: parsed.searchParams.get('startMenu') === '1',
|
||||
desktopEnabled: parsed.searchParams.get('desktop') === '1',
|
||||
};
|
||||
}
|
||||
return { action };
|
||||
}
|
||||
|
||||
@@ -238,15 +319,15 @@ export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
|
||||
}
|
||||
|
||||
export function createHandleFirstRunSetupNavigationHandler(deps: {
|
||||
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
|
||||
handleAction: (action: FirstRunSetupAction) => Promise<unknown>;
|
||||
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
|
||||
handleAction: (submission: FirstRunSetupSubmission) => Promise<unknown>;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return (params: { url: string; preventDefault: () => void }): boolean => {
|
||||
const submission = deps.parseSubmissionUrl(params.url);
|
||||
if (!submission) return false;
|
||||
params.preventDefault();
|
||||
void deps.handleAction(submission.action).catch((error) => {
|
||||
void deps.handleAction(submission).catch((error) => {
|
||||
deps.logError('Failed handling first-run setup action', error);
|
||||
});
|
||||
return true;
|
||||
@@ -260,11 +341,13 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
createSetupWindow: () => TWindow;
|
||||
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
|
||||
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
|
||||
parseSubmissionUrl: (rawUrl: string) => { action: FirstRunSetupAction } | null;
|
||||
handleAction: (action: FirstRunSetupAction) => Promise<{ closeWindow?: boolean } | void>;
|
||||
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
|
||||
handleAction: (submission: FirstRunSetupSubmission) => Promise<{ closeWindow?: boolean } | void>;
|
||||
markSetupInProgress: () => Promise<unknown>;
|
||||
markSetupCancelled: () => Promise<unknown>;
|
||||
isSetupCompleted: () => boolean;
|
||||
shouldQuitWhenClosedIncomplete: () => boolean;
|
||||
quitApp: () => void;
|
||||
clearSetupWindow: () => void;
|
||||
setSetupWindow: (window: TWindow) => void;
|
||||
encodeURIComponent: (value: string) => string;
|
||||
@@ -286,8 +369,8 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
|
||||
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
|
||||
parseSubmissionUrl: deps.parseSubmissionUrl,
|
||||
handleAction: async (action) => {
|
||||
const result = await deps.handleAction(action);
|
||||
handleAction: async (submission) => {
|
||||
const result = await deps.handleAction(submission);
|
||||
if (result?.closeWindow) {
|
||||
if (!setupWindow.isDestroyed()) {
|
||||
setupWindow.close();
|
||||
@@ -313,12 +396,16 @@ export function createOpenFirstRunSetupWindowHandler<
|
||||
});
|
||||
|
||||
setupWindow.on('closed', () => {
|
||||
if (!deps.isSetupCompleted()) {
|
||||
const setupCompleted = deps.isSetupCompleted();
|
||||
if (!setupCompleted) {
|
||||
void deps.markSetupCancelled().catch((error) => {
|
||||
deps.logError('Failed marking first-run setup cancelled', error);
|
||||
});
|
||||
}
|
||||
deps.clearSetupWindow();
|
||||
if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) {
|
||||
deps.quitApp();
|
||||
}
|
||||
});
|
||||
|
||||
void deps
|
||||
|
||||
@@ -7,6 +7,7 @@ test('initial args handler no-ops without initial args', () => {
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => null,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => false,
|
||||
@@ -26,6 +27,7 @@ test('initial args handler ensures tray in background mode', () => {
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
ensureTray: () => {
|
||||
ensuredTray = true;
|
||||
},
|
||||
@@ -46,6 +48,7 @@ test('initial args handler auto-connects mpv when needed', () => {
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
@@ -71,6 +74,7 @@ test('initial args handler forwards args to cli handler', () => {
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
ensureTray: () => {},
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => false,
|
||||
@@ -84,3 +88,23 @@ test('initial args handler forwards args to cli handler', () => {
|
||||
handleInitialArgs();
|
||||
assert.deepEqual(seenSources, ['initial']);
|
||||
});
|
||||
|
||||
test('initial args handler can ensure tray outside background mode when requested', () => {
|
||||
let ensuredTray = false;
|
||||
const handleInitialArgs = createHandleInitialArgsHandler({
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => false,
|
||||
shouldEnsureTrayOnStartup: () => true,
|
||||
ensureTray: () => {
|
||||
ensuredTray = true;
|
||||
},
|
||||
isTexthookerOnlyMode: () => true,
|
||||
hasImmersionTracker: () => false,
|
||||
getMpvClient: () => null,
|
||||
logInfo: () => {},
|
||||
handleCliCommand: () => {},
|
||||
});
|
||||
|
||||
handleInitialArgs();
|
||||
assert.equal(ensuredTray, true);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ type MpvClientLike = {
|
||||
export function createHandleInitialArgsHandler(deps: {
|
||||
getInitialArgs: () => CliArgs | null;
|
||||
isBackgroundMode: () => boolean;
|
||||
shouldEnsureTrayOnStartup: () => boolean;
|
||||
ensureTray: () => void;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
hasImmersionTracker: () => boolean;
|
||||
@@ -19,7 +20,7 @@ export function createHandleInitialArgsHandler(deps: {
|
||||
const initialArgs = deps.getInitialArgs();
|
||||
if (!initialArgs) return;
|
||||
|
||||
if (deps.isBackgroundMode()) {
|
||||
if (deps.isBackgroundMode() || deps.shouldEnsureTrayOnStartup()) {
|
||||
deps.ensureTray();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
||||
const deps = createBuildHandleInitialArgsMainDepsHandler({
|
||||
getInitialArgs: () => args,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
ensureTray: () => calls.push('ensure-tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
@@ -19,6 +20,7 @@ test('initial args main deps builder maps runtime callbacks and state readers',
|
||||
|
||||
assert.equal(deps.getInitialArgs(), args);
|
||||
assert.equal(deps.isBackgroundMode(), true);
|
||||
assert.equal(deps.shouldEnsureTrayOnStartup(), false);
|
||||
assert.equal(deps.isTexthookerOnlyMode(), false);
|
||||
assert.equal(deps.hasImmersionTracker(), true);
|
||||
assert.equal(deps.getMpvClient(), mpvClient);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CliArgs } from '../../cli/args';
|
||||
export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
||||
getInitialArgs: () => CliArgs | null;
|
||||
isBackgroundMode: () => boolean;
|
||||
shouldEnsureTrayOnStartup: () => boolean;
|
||||
ensureTray: () => void;
|
||||
isTexthookerOnlyMode: () => boolean;
|
||||
hasImmersionTracker: () => boolean;
|
||||
@@ -13,6 +14,7 @@ export function createBuildHandleInitialArgsMainDepsHandler(deps: {
|
||||
return () => ({
|
||||
getInitialArgs: () => deps.getInitialArgs(),
|
||||
isBackgroundMode: () => deps.isBackgroundMode(),
|
||||
shouldEnsureTrayOnStartup: () => deps.shouldEnsureTrayOnStartup(),
|
||||
ensureTray: () => deps.ensureTray(),
|
||||
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
|
||||
hasImmersionTracker: () => deps.hasImmersionTracker(),
|
||||
|
||||
@@ -7,6 +7,7 @@ test('initial args runtime handler composes main deps and runs initial command f
|
||||
const handleInitialArgs = createInitialArgsRuntimeHandler({
|
||||
getInitialArgs: () => ({ start: true }) as never,
|
||||
isBackgroundMode: () => true,
|
||||
shouldEnsureTrayOnStartup: () => false,
|
||||
ensureTray: () => calls.push('tray'),
|
||||
isTexthookerOnlyMode: () => false,
|
||||
hasImmersionTracker: () => true,
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -13,8 +13,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
||||
calls.push(`registered:${registered}`);
|
||||
},
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
isMacOSPlatform: () => true,
|
||||
isTrackedMpvWindowFocused: () => false,
|
||||
isOverlayShortcutContextActive: () => false,
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openJimaku: () => calls.push('jimaku'),
|
||||
@@ -42,8 +41,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
|
||||
})();
|
||||
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), true);
|
||||
assert.equal(deps.isMacOSPlatform(), true);
|
||||
assert.equal(deps.isTrackedMpvWindowFocused(), false);
|
||||
assert.equal(deps.isOverlayShortcutContextActive?.(), false);
|
||||
assert.equal(deps.getShortcutsRegistered(), false);
|
||||
deps.setShortcutsRegistered(true);
|
||||
assert.equal(shortcutsRegistered, true);
|
||||
|
||||
@@ -8,8 +8,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
|
||||
getShortcutsRegistered: () => deps.getShortcutsRegistered(),
|
||||
setShortcutsRegistered: (registered: boolean) => deps.setShortcutsRegistered(registered),
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
isMacOSPlatform: () => deps.isMacOSPlatform(),
|
||||
isTrackedMpvWindowFocused: () => deps.isTrackedMpvWindowFocused(),
|
||||
isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true,
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
openJimaku: () => deps.openJimaku(),
|
||||
|
||||
@@ -25,6 +25,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
enforceOverlayLayerOrder: () => calls.push('enforce-order'),
|
||||
syncOverlayShortcuts: () => calls.push('sync-shortcuts'),
|
||||
isMacOSPlatform: () => true,
|
||||
isWindowsPlatform: () => false,
|
||||
showOverlayLoadingOsd: () => calls.push('overlay-loading-osd'),
|
||||
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 20, height: 20 }),
|
||||
})();
|
||||
@@ -39,6 +40,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
deps.enforceOverlayLayerOrder();
|
||||
deps.syncOverlayShortcuts();
|
||||
assert.equal(deps.isMacOSPlatform(), true);
|
||||
assert.equal(deps.isWindowsPlatform(), false);
|
||||
deps.showOverlayLoadingOsd('Overlay loading...');
|
||||
assert.deepEqual(deps.resolveFallbackBounds(), { x: 0, y: 0, width: 20, height: 20 });
|
||||
assert.equal(trackerNotReadyWarningShown, true);
|
||||
|
||||
@@ -18,6 +18,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
enforceOverlayLayerOrder: () => deps.enforceOverlayLayerOrder(),
|
||||
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||
isMacOSPlatform: () => deps.isMacOSPlatform(),
|
||||
isWindowsPlatform: () => deps.isWindowsPlatform(),
|
||||
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
|
||||
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
buildTrayMenuTemplateRuntime: (handlers) => {
|
||||
handlers.openOverlay();
|
||||
handlers.openFirstRunSetup();
|
||||
handlers.openWindowsMpvLauncherSetup();
|
||||
handlers.openYomitanSettings();
|
||||
handlers.openRuntimeOptions();
|
||||
handlers.openJellyfinSetup();
|
||||
@@ -58,6 +59,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
@@ -71,6 +73,7 @@ test('build tray template handler wires actions and init guards', () => {
|
||||
'init',
|
||||
'visible:true',
|
||||
'setup',
|
||||
'setup',
|
||||
'yomitan',
|
||||
'runtime-options',
|
||||
'jellyfin',
|
||||
|
||||
@@ -31,6 +31,8 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
openOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
@@ -42,6 +44,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
@@ -60,6 +63,10 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
|
||||
deps.openFirstRunSetupWindow();
|
||||
},
|
||||
showFirstRunSetup: deps.showFirstRunSetup(),
|
||||
openWindowsMpvLauncherSetup: () => {
|
||||
deps.openFirstRunSetupWindow();
|
||||
},
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup(),
|
||||
openYomitanSettings: () => {
|
||||
deps.openYomitanSettings();
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
setVisibleOverlayVisible: (visible) => calls.push(`visible:${visible}`),
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => calls.push('setup'),
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
|
||||
openJellyfinSetupWindow: () => calls.push('jellyfin'),
|
||||
@@ -38,6 +39,8 @@ test('tray main deps builders return mapped handlers', () => {
|
||||
openOverlay: () => calls.push('open-overlay'),
|
||||
openFirstRunSetup: () => calls.push('open-setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('open-windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
openYomitanSettings: () => calls.push('open-yomitan'),
|
||||
openRuntimeOptions: () => calls.push('open-runtime-options'),
|
||||
openJellyfinSetup: () => calls.push('open-jellyfin'),
|
||||
|
||||
@@ -30,6 +30,8 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
openOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
@@ -41,6 +43,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
showFirstRunSetup: () => boolean;
|
||||
openFirstRunSetupWindow: () => void;
|
||||
showWindowsMpvLauncherSetup: () => boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJellyfinSetupWindow: () => void;
|
||||
@@ -54,6 +57,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
|
||||
setVisibleOverlayVisible: deps.setVisibleOverlayVisible,
|
||||
showFirstRunSetup: deps.showFirstRunSetup,
|
||||
openFirstRunSetupWindow: deps.openFirstRunSetupWindow,
|
||||
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
|
||||
openYomitanSettings: deps.openYomitanSettings,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
|
||||
|
||||
@@ -29,6 +29,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
|
||||
},
|
||||
showFirstRunSetup: () => true,
|
||||
openFirstRunSetupWindow: () => {},
|
||||
showWindowsMpvLauncherSetup: () => true,
|
||||
openYomitanSettings: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openJellyfinSetupWindow: () => {},
|
||||
|
||||
@@ -32,6 +32,8 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
openOverlay: () => calls.push('overlay'),
|
||||
openFirstRunSetup: () => calls.push('setup'),
|
||||
showFirstRunSetup: true,
|
||||
openWindowsMpvLauncherSetup: () => calls.push('windows-mpv'),
|
||||
showWindowsMpvLauncherSetup: true,
|
||||
openYomitanSettings: () => calls.push('yomitan'),
|
||||
openRuntimeOptions: () => calls.push('runtime'),
|
||||
openJellyfinSetup: () => calls.push('jellyfin'),
|
||||
@@ -39,10 +41,10 @@ test('tray menu template contains expected entries and handlers', () => {
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
assert.equal(template.length, 8);
|
||||
assert.equal(template.length, 9);
|
||||
template[0]!.click?.();
|
||||
template[6]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[7]!.click?.();
|
||||
template[7]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
|
||||
template[8]!.click?.();
|
||||
assert.deepEqual(calls, ['overlay', 'separator', 'quit']);
|
||||
});
|
||||
|
||||
@@ -51,6 +53,8 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
openOverlay: () => undefined,
|
||||
openFirstRunSetup: () => undefined,
|
||||
showFirstRunSetup: false,
|
||||
openWindowsMpvLauncherSetup: () => undefined,
|
||||
showWindowsMpvLauncherSetup: false,
|
||||
openYomitanSettings: () => undefined,
|
||||
openRuntimeOptions: () => undefined,
|
||||
openJellyfinSetup: () => undefined,
|
||||
@@ -61,4 +65,5 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
|
||||
.filter(Boolean);
|
||||
|
||||
assert.equal(labels.includes('Complete Setup'), false);
|
||||
assert.equal(labels.includes('Manage Windows mpv launcher'), false);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ export type TrayMenuActionHandlers = {
|
||||
openOverlay: () => void;
|
||||
openFirstRunSetup: () => void;
|
||||
showFirstRunSetup: boolean;
|
||||
openWindowsMpvLauncherSetup: () => void;
|
||||
showWindowsMpvLauncherSetup: boolean;
|
||||
openYomitanSettings: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJellyfinSetup: () => void;
|
||||
@@ -58,6 +60,14 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(handlers.showWindowsMpvLauncherSetup
|
||||
? [
|
||||
{
|
||||
label: 'Manage Windows mpv launcher',
|
||||
click: handlers.openWindowsMpvLauncherSetup,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Open Yomitan Settings',
|
||||
click: handlers.openYomitanSettings,
|
||||
|
||||
106
src/main/runtime/windows-mpv-launch.test.ts
Normal file
106
src/main/runtime/windows-mpv-launch.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildWindowsMpvLaunchArgs,
|
||||
launchWindowsMpv,
|
||||
resolveWindowsMpvPath,
|
||||
type WindowsMpvLaunchDeps,
|
||||
} from './windows-mpv-launch';
|
||||
|
||||
function createDeps(overrides: Partial<WindowsMpvLaunchDeps> = {}): WindowsMpvLaunchDeps {
|
||||
return {
|
||||
getEnv: () => undefined,
|
||||
runWhere: () => ({ status: 1, stdout: '' }),
|
||||
fileExists: () => false,
|
||||
spawnDetached: () => undefined,
|
||||
showError: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('resolveWindowsMpvPath prefers SUBMINER_MPV_PATH', () => {
|
||||
const resolved = resolveWindowsMpvPath(
|
||||
createDeps({
|
||||
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
|
||||
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
|
||||
});
|
||||
|
||||
test('resolveWindowsMpvPath falls back to where.exe output', () => {
|
||||
const resolved = resolveWindowsMpvPath(
|
||||
createDeps({
|
||||
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\nC:\\other\\mpv.exe\r\n' }),
|
||||
fileExists: (candidate) => candidate === 'C:\\tools\\mpv.exe',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(resolved, 'C:\\tools\\mpv.exe');
|
||||
});
|
||||
|
||||
test('buildWindowsMpvLaunchArgs keeps pseudo-gui profile and targets', () => {
|
||||
assert.deepEqual(buildWindowsMpvLaunchArgs(['C:\\a.mkv', 'C:\\b.mkv']), [
|
||||
'--player-operation-mode=pseudo-gui',
|
||||
'--profile=subminer',
|
||||
'C:\\a.mkv',
|
||||
'C:\\b.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv reports missing mpv path', () => {
|
||||
const errors: string[] = [];
|
||||
const result = launchWindowsMpv(
|
||||
[],
|
||||
createDeps({
|
||||
showError: (_title, content) => errors.push(content),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.mpvPath, '');
|
||||
assert.match(errors[0] ?? '', /Could not find mpv\.exe/i);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv spawns detached mpv with targets', () => {
|
||||
const calls: string[] = [];
|
||||
const result = launchWindowsMpv(
|
||||
['C:\\video.mkv'],
|
||||
createDeps({
|
||||
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
|
||||
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
|
||||
spawnDetached: (command, args) => {
|
||||
calls.push(command);
|
||||
calls.push(args.join('|'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
|
||||
assert.deepEqual(calls, [
|
||||
'C:\\mpv\\mpv.exe',
|
||||
'--player-operation-mode=pseudo-gui|--profile=subminer|C:\\video.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv reports spawn failures with path context', () => {
|
||||
const errors: string[] = [];
|
||||
const result = launchWindowsMpv(
|
||||
[],
|
||||
createDeps({
|
||||
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
|
||||
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
|
||||
spawnDetached: () => {
|
||||
throw new Error('spawn failed');
|
||||
},
|
||||
showError: (_title, content) => errors.push(content),
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.mpvPath, 'C:\\mpv\\mpv.exe');
|
||||
assert.match(errors[0] ?? '', /Failed to launch mpv/i);
|
||||
assert.match(errors[0] ?? '', /C:\\mpv\\mpv\.exe/i);
|
||||
});
|
||||
100
src/main/runtime/windows-mpv-launch.ts
Normal file
100
src/main/runtime/windows-mpv-launch.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import fs from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
|
||||
export interface WindowsMpvLaunchDeps {
|
||||
getEnv: (name: string) => string | undefined;
|
||||
runWhere: () => { status: number | null; stdout: string; error?: Error };
|
||||
fileExists: (candidate: string) => boolean;
|
||||
spawnDetached: (command: string, args: string[]) => void;
|
||||
showError: (title: string, content: string) => void;
|
||||
}
|
||||
|
||||
function normalizeCandidate(candidate: string | undefined): string {
|
||||
return typeof candidate === 'string' ? candidate.trim() : '';
|
||||
}
|
||||
|
||||
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
|
||||
const envPath = normalizeCandidate(deps.getEnv('SUBMINER_MPV_PATH'));
|
||||
if (envPath && deps.fileExists(envPath)) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
const whereResult = deps.runWhere();
|
||||
if (whereResult.status === 0) {
|
||||
const firstPath = whereResult.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && deps.fileExists(line));
|
||||
if (firstPath) {
|
||||
return firstPath;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildWindowsMpvLaunchArgs(targets: string[]): string[] {
|
||||
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...targets];
|
||||
}
|
||||
|
||||
export function launchWindowsMpv(
|
||||
targets: string[],
|
||||
deps: WindowsMpvLaunchDeps,
|
||||
): { ok: boolean; mpvPath: string } {
|
||||
const mpvPath = resolveWindowsMpvPath(deps);
|
||||
if (!mpvPath) {
|
||||
deps.showError(
|
||||
'SubMiner mpv launcher',
|
||||
'Could not find mpv.exe. Install mpv and add it to PATH, or set SUBMINER_MPV_PATH.',
|
||||
);
|
||||
return { ok: false, mpvPath: '' };
|
||||
}
|
||||
|
||||
try {
|
||||
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets));
|
||||
return { ok: true, mpvPath };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.showError('SubMiner mpv launcher', `Failed to launch mpv.\nPath: ${mpvPath}\n${message}`);
|
||||
return { ok: false, mpvPath };
|
||||
}
|
||||
}
|
||||
|
||||
export function createWindowsMpvLaunchDeps(options: {
|
||||
getEnv?: (name: string) => string | undefined;
|
||||
fileExists?: (candidate: string) => boolean;
|
||||
showError: (title: string, content: string) => void;
|
||||
}): WindowsMpvLaunchDeps {
|
||||
return {
|
||||
getEnv: options.getEnv ?? ((name) => process.env[name]),
|
||||
runWhere: () => {
|
||||
const result = spawnSync('where.exe', ['mpv.exe'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout ?? '',
|
||||
error: result.error ?? undefined,
|
||||
};
|
||||
},
|
||||
fileExists:
|
||||
options.fileExists ??
|
||||
((candidate) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
spawnDetached: (command, args) => {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
},
|
||||
showError: options.showError,
|
||||
};
|
||||
}
|
||||
130
src/main/runtime/windows-mpv-shortcuts.test.ts
Normal file
130
src/main/runtime/windows-mpv-shortcuts.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyWindowsMpvShortcuts,
|
||||
buildWindowsMpvShortcutDetails,
|
||||
detectWindowsMpvShortcuts,
|
||||
resolveWindowsMpvShortcutPaths,
|
||||
resolveWindowsStartMenuProgramsDir,
|
||||
} from './windows-mpv-shortcuts';
|
||||
|
||||
test('resolveWindowsStartMenuProgramsDir derives Programs folder from APPDATA', () => {
|
||||
assert.equal(
|
||||
resolveWindowsStartMenuProgramsDir('C:\\Users\\tester\\AppData\\Roaming'),
|
||||
'C:\\Users\\tester\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveWindowsMpvShortcutPaths builds start menu and desktop lnk paths', () => {
|
||||
const paths = resolveWindowsMpvShortcutPaths({
|
||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||
desktopDir: 'C:\\Users\\tester\\Desktop',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
paths.startMenuPath,
|
||||
'C:\\Users\\tester\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\SubMiner mpv.lnk',
|
||||
);
|
||||
assert.equal(paths.desktopPath, 'C:\\Users\\tester\\Desktop\\SubMiner mpv.lnk');
|
||||
});
|
||||
|
||||
test('buildWindowsMpvShortcutDetails targets SubMiner.exe with --launch-mpv', () => {
|
||||
assert.deepEqual(buildWindowsMpvShortcutDetails('C:\\Apps\\SubMiner\\SubMiner.exe'), {
|
||||
target: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||
args: '--launch-mpv',
|
||||
cwd: 'C:\\Apps\\SubMiner',
|
||||
description: 'Launch mpv with the SubMiner profile',
|
||||
icon: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||
iconIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('detectWindowsMpvShortcuts reflects existing shortcuts', () => {
|
||||
const detected = detectWindowsMpvShortcuts(
|
||||
{
|
||||
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
|
||||
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
|
||||
},
|
||||
(candidate) => candidate === 'C:\\Desktop\\SubMiner mpv.lnk',
|
||||
);
|
||||
|
||||
assert.deepEqual(detected, {
|
||||
startMenuInstalled: false,
|
||||
desktopInstalled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('applyWindowsMpvShortcuts creates enabled shortcuts and removes disabled ones', () => {
|
||||
const writes: string[] = [];
|
||||
const removes: string[] = [];
|
||||
const result = applyWindowsMpvShortcuts({
|
||||
preferences: {
|
||||
startMenuEnabled: true,
|
||||
desktopEnabled: false,
|
||||
},
|
||||
paths: {
|
||||
startMenuPath: 'C:\\Programs\\SubMiner mpv.lnk',
|
||||
desktopPath: 'C:\\Desktop\\SubMiner mpv.lnk',
|
||||
},
|
||||
exePath: 'C:\\Apps\\SubMiner\\SubMiner.exe',
|
||||
writeShortcutLink: (shortcutPath, operation, details) => {
|
||||
writes.push(`${shortcutPath}|${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/);
|
||||
});
|
||||
117
src/main/runtime/windows-mpv-shortcuts.ts
Normal file
117
src/main/runtime/windows-mpv-shortcuts.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const WINDOWS_MPV_SHORTCUT_NAME = 'SubMiner mpv.lnk';
|
||||
|
||||
export interface WindowsMpvShortcutPaths {
|
||||
startMenuPath: string;
|
||||
desktopPath: string;
|
||||
}
|
||||
|
||||
export interface WindowsShortcutLinkDetails {
|
||||
target: string;
|
||||
args?: string;
|
||||
cwd?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
iconIndex?: number;
|
||||
}
|
||||
|
||||
export interface WindowsMpvShortcutInstallResult {
|
||||
ok: boolean;
|
||||
status: 'installed' | 'skipped' | 'failed';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function resolveWindowsStartMenuProgramsDir(appDataDir: string): string {
|
||||
return path.join(appDataDir, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
|
||||
}
|
||||
|
||||
export function resolveWindowsMpvShortcutPaths(options: {
|
||||
appDataDir: string;
|
||||
desktopDir: string;
|
||||
}): WindowsMpvShortcutPaths {
|
||||
return {
|
||||
startMenuPath: path.join(resolveWindowsStartMenuProgramsDir(options.appDataDir), WINDOWS_MPV_SHORTCUT_NAME),
|
||||
desktopPath: path.join(options.desktopDir, WINDOWS_MPV_SHORTCUT_NAME),
|
||||
};
|
||||
}
|
||||
|
||||
export function detectWindowsMpvShortcuts(
|
||||
paths: WindowsMpvShortcutPaths,
|
||||
existsSync: (candidate: string) => boolean = fs.existsSync,
|
||||
): { startMenuInstalled: boolean; desktopInstalled: boolean } {
|
||||
return {
|
||||
startMenuInstalled: existsSync(paths.startMenuPath),
|
||||
desktopInstalled: existsSync(paths.desktopPath),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWindowsMpvShortcutDetails(exePath: string): WindowsShortcutLinkDetails {
|
||||
return {
|
||||
target: exePath,
|
||||
args: '--launch-mpv',
|
||||
cwd: path.dirname(exePath),
|
||||
description: 'Launch mpv with the SubMiner profile',
|
||||
icon: exePath,
|
||||
iconIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyWindowsMpvShortcuts(options: {
|
||||
preferences: { startMenuEnabled: boolean; desktopEnabled: boolean };
|
||||
paths: WindowsMpvShortcutPaths;
|
||||
exePath: string;
|
||||
writeShortcutLink: (
|
||||
shortcutPath: string,
|
||||
operation: 'create' | 'update' | 'replace',
|
||||
details: WindowsShortcutLinkDetails,
|
||||
) => boolean;
|
||||
rmSync?: (candidate: string, options: { force: true }) => void;
|
||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||
}): WindowsMpvShortcutInstallResult {
|
||||
const rmSync = options.rmSync ?? fs.rmSync;
|
||||
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
||||
const details = buildWindowsMpvShortcutDetails(options.exePath);
|
||||
const failures: string[] = [];
|
||||
|
||||
const ensureShortcut = (shortcutPath: string): void => {
|
||||
mkdirSync(path.dirname(shortcutPath), { recursive: true });
|
||||
const ok = options.writeShortcutLink(shortcutPath, '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.',
|
||||
};
|
||||
}
|
||||
66
src/main/runtime/yomitan-anki-server.test.ts
Normal file
66
src/main/runtime/yomitan-anki-server.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { AnkiConnectConfig } from '../../types';
|
||||
import {
|
||||
getPreferredYomitanAnkiServerUrl,
|
||||
shouldForceOverrideYomitanAnkiServer,
|
||||
} from './yomitan-anki-server';
|
||||
|
||||
function createConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
url: 'http://127.0.0.1:8765',
|
||||
proxy: {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8766,
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
...overrides,
|
||||
} as AnkiConnectConfig;
|
||||
}
|
||||
|
||||
test('prefers upstream AnkiConnect when SubMiner integration is disabled', () => {
|
||||
const config = createConfig({
|
||||
enabled: false,
|
||||
proxy: {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 8766,
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:8765');
|
||||
assert.equal(shouldForceOverrideYomitanAnkiServer(config), false);
|
||||
});
|
||||
|
||||
test('prefers SubMiner proxy when SubMiner integration and proxy are enabled', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
proxy: {
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 9988,
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:9988');
|
||||
assert.equal(shouldForceOverrideYomitanAnkiServer(config), true);
|
||||
});
|
||||
|
||||
test('falls back to upstream AnkiConnect when proxy transport is disabled', () => {
|
||||
const config = createConfig({
|
||||
enabled: true,
|
||||
proxy: {
|
||||
enabled: false,
|
||||
host: '127.0.0.1',
|
||||
port: 8766,
|
||||
upstreamUrl: 'http://127.0.0.1:8765',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(getPreferredYomitanAnkiServerUrl(config), 'http://127.0.0.1:8765');
|
||||
assert.equal(shouldForceOverrideYomitanAnkiServer(config), false);
|
||||
});
|
||||
15
src/main/runtime/yomitan-anki-server.ts
Normal file
15
src/main/runtime/yomitan-anki-server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { AnkiConnectConfig } from '../../types';
|
||||
|
||||
export function getPreferredYomitanAnkiServerUrl(config: AnkiConnectConfig): string {
|
||||
if (config.enabled === true && config.proxy?.enabled === true) {
|
||||
const host = config.proxy.host || '127.0.0.1';
|
||||
const port = config.proxy.port || 8766;
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
return config.url || 'http://127.0.0.1:8765';
|
||||
}
|
||||
|
||||
export function shouldForceOverrideYomitanAnkiServer(config: AnkiConnectConfig): boolean {
|
||||
return config.enabled === true && config.proxy?.enabled === true;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user