Compare commits

...

7 Commits

Author SHA1 Message Date
sudacode 799cce6991 fix(docs): correct versioned nav links and local dev version routing (#74) 2026-05-18 01:07:17 -07:00
sudacode 6b2cb002ac [codex] add versioned Pages deployment (#73) 2026-05-17 19:54:59 -07:00
sudacode e84674e3b5 feat(macos): configuration window + curl-backed macOS updater (#71) 2026-05-17 02:23:44 -07:00
sudacode 6ca5cede3e feat(launcher): add --version / -v flag to print app version
- Exits early with `SubMiner <version>` output, no app binary required
- Maps `-v` at root level without conflicting with `stats cleanup -v`
- Adds `version: boolean` to `Args` type and default args
- Documents flag in docs-site and adds changelog fragment
2026-05-16 22:47:57 -07:00
sudacode 4d010e6a18 chore(release): prepare 0.15.0-beta.3 2026-05-16 20:58:12 -07:00
sudacode 5250ca8214 fix(controller): save remaps per profile, gate modals on enabled (#69) 2026-05-16 20:43:27 -07:00
sudacode 49f89e6452 Fix macOS overlay foreground handling and character-dictionary cache reuse (#68) 2026-05-16 20:43:01 -07:00
206 changed files with 19686 additions and 1014 deletions
+76
View File
@@ -0,0 +1,76 @@
name: Docs Pages
on:
workflow_dispatch:
push:
branches:
- main
tags:
- 'v*'
paths:
- 'docs-site/**'
- 'scripts/docs-versioning.ts'
- 'scripts/build-versioned-docs.ts'
- '.github/workflows/docs-pages.yml'
- 'package.json'
- 'bun.lock'
concurrency:
group: docs-pages-production
cancel-in-progress: false
jobs:
deploy:
if: ${{ github.ref_type != 'tag' || !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Guard stable docs tag shape
id: tag_guard
if: github.ref_type == 'tag'
run: |
if [[ ! "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::notice::Skipping non-stable docs tag ${{ github.ref_name }}"
echo "stable_tag=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "stable_tag=true" >> "$GITHUB_OUTPUT"
- name: Setup Bun
if: steps.tag_guard.outputs.stable_tag != 'false'
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Install dependencies
if: steps.tag_guard.outputs.stable_tag != 'false'
run: |
bun install --frozen-lockfile
cd docs-site && bun install --frozen-lockfile
- name: Cache versioned docs archives
if: steps.tag_guard.outputs.stable_tag != 'false'
uses: actions/cache@v4
with:
path: .tmp/docs-versioned-archive-cache
key: docs-versioned-archives-${{ runner.os }}-${{ hashFiles('docs-site/.vitepress/**', 'docs-site/public/assets/fonts/**', 'docs-site/package.json', 'docs-site/bun.lock', 'scripts/build-versioned-docs.ts', 'scripts/docs-versioning.ts') }}
- name: Test docs
if: steps.tag_guard.outputs.stable_tag != 'false'
run: bun run docs:test
- name: Build versioned docs
if: steps.tag_guard.outputs.stable_tag != 'false'
run: bun run docs:build:versioned
- name: Deploy docs to Cloudflare Pages
if: steps.tag_guard.outputs.stable_tag != 'false'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy .tmp/docs-versioned-site --project-name "${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }}" --branch main
+27 -2
View File
@@ -1,4 +1,4 @@
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint 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 uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
APP_NAME := subminer
THEME_SOURCE := assets/themes/subminer.rasi
@@ -23,6 +23,7 @@ MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
PRERELEASE_NOTES := release/prerelease-notes.md
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
ifeq ($(OS),Windows_NT)
@@ -61,6 +62,10 @@ help:
" dev-watch-macos Start watch loop with forced macOS tracker backend" \
" dev-toggle Toggle overlay in a running local Electron app" \
" dev-stop Stop a running local Electron app" \
" docs-test Run docs tests" \
" docs-build Build the docs site" \
" docs-build-versioned Build production versioned docs site" \
" docs-dev Start the docs dev server" \
" install-linux Install Linux wrapper/theme/app artifacts" \
" install-macos Install macOS wrapper/theme/app artifacts" \
" install-windows Print Windows packaging/install guidance" \
@@ -161,7 +166,15 @@ build-launcher:
clean:
@printf '%s\n' "[INFO] Removing build artifacts"
@rm -rf dist release
@if [ -f "$(PRERELEASE_NOTES)" ]; then \
PRERELEASE_NOTES_BACKUP="$$(mktemp -t subminer-prerelease-notes.XXXXXX)" && \
cp "$(PRERELEASE_NOTES)" "$$PRERELEASE_NOTES_BACKUP" && \
rm -rf dist release && \
install -d release && \
mv "$$PRERELEASE_NOTES_BACKUP" "$(PRERELEASE_NOTES)"; \
else \
rm -rf dist release; \
fi
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
generate-config: ensure-bun
@@ -191,6 +204,18 @@ dev-toggle: ensure-bun
dev-stop: ensure-bun
@bun run electron . --stop
docs-test: ensure-bun
@bun run docs:test
docs-build: ensure-bun
@bun run docs:build
docs-build-versioned: ensure-bun
@bun run docs:build:versioned
docs-dev: ensure-bun
@bun run docs:dev
install-linux: build-launcher
@printf '%s\n' "[INFO] Installing Linux wrapper/theme artifacts"
+193
View File
@@ -0,0 +1,193 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveMacAppBundlePath = resolveMacAppBundlePath;
exports.isMacApplicationsFolderBundle = isMacApplicationsFolderBundle;
exports.isKnownLinuxPackageManagedAppImage = isKnownLinuxPackageManagedAppImage;
exports.isNativeUpdaterSupported = isNativeUpdaterSupported;
exports.configureAutoUpdater = configureAutoUpdater;
exports.createElectronAppUpdater = createElectronAppUpdater;
const node_fs_1 = require("node:fs");
const node_child_process_1 = require("node:child_process");
const node_os_1 = __importDefault(require("node:os"));
const node_path_1 = __importDefault(require("node:path"));
const node_util_1 = require("node:util");
const electron_updater_1 = require("electron-updater");
const release_assets_1 = require("./release-assets");
const updaterErrorListeners = new WeakMap();
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
function resolveMacAppBundlePath(execPath) {
const marker = '.app/Contents/MacOS/';
const markerIndex = execPath.indexOf(marker);
if (markerIndex < 0)
return null;
return execPath.slice(0, markerIndex + '.app'.length);
}
async function readMacCodeSignature(appBundlePath) {
try {
const result = await execFileAsync('/usr/bin/codesign', ['-dv', '--verbose=4', appBundlePath], {
encoding: 'utf8',
});
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
}
catch {
return null;
}
}
function realpathOrOriginal(filePath) {
try {
return (0, node_fs_1.realpathSync)(filePath);
}
catch {
return filePath;
}
}
function isSameOrInsideDirectory(parentPath, candidatePath) {
const relative = node_path_1.default.relative(parentPath, candidatePath);
return (relative === '' ||
(relative.length > 0 && !relative.startsWith('..') && !node_path_1.default.isAbsolute(relative)));
}
function isMacApplicationsFolderBundle(appBundlePath, homeDir = node_os_1.default.homedir()) {
const resolvedBundlePath = node_path_1.default.resolve(appBundlePath);
return (isSameOrInsideDirectory('/Applications', resolvedBundlePath) ||
isSameOrInsideDirectory(node_path_1.default.join(homeDir, 'Applications'), resolvedBundlePath));
}
function isKnownLinuxPackageManagedAppImage(appImagePath) {
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
}
async function isNativeUpdaterSupported(options) {
if (!options.isPackaged) {
options.log?.('Skipping native updater because this build is not packaged.');
return false;
}
if (options.platform === 'linux') {
options.log?.('Skipping native Linux updater because Linux tray checks use GitHub release assets.');
return false;
}
if (options.platform !== 'darwin') {
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
return false;
}
const appBundlePath = resolveMacAppBundlePath(options.execPath);
if (!appBundlePath) {
options.log?.('Skipping native macOS updater because the app bundle path could not be resolved.');
return false;
}
if (!isMacApplicationsFolderBundle(appBundlePath, options.homeDir)) {
options.log?.('Skipping native macOS updater because the app is not installed in an Applications folder.');
return false;
}
const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
if (!signature) {
options.log?.('Skipping native macOS updater because the app code signature could not be read.');
return false;
}
if (/Signature=adhoc\b/.test(signature) || /TeamIdentifier=not set\b/.test(signature)) {
options.log?.('Skipping native macOS updater because this build is ad-hoc signed.');
return false;
}
return true;
}
function configureAutoUpdater(updater, log = () => { }, channel = 'stable') {
updater.autoDownload = false;
// On macOS this avoids invoking Squirrel until the explicit restart/install step.
updater.autoInstallOnAppQuit = false;
updater.allowPrerelease = channel === 'prerelease';
updater.allowDowngrade = false;
updater.logger = {
info: () => { },
debug: () => { },
warn: (message) => log(message),
error: (message) => log(message),
};
const previousErrorListener = updaterErrorListeners.get(updater);
if (previousErrorListener) {
if (updater.off) {
updater.off('error', previousErrorListener);
}
else {
updater.removeListener?.('error', previousErrorListener);
}
}
if (updater.on) {
const errorListener = (error) => {
const message = error instanceof Error ? error.message : String(error);
log(`Updater error event: ${message}`);
};
updater.on('error', errorListener);
updaterErrorListeners.set(updater, errorListener);
}
return updater;
}
function createElectronAppUpdater(options) {
const getChannel = options.getChannel ?? (() => 'stable');
const updater = configureAutoUpdater(options.updater ?? electron_updater_1.autoUpdater, options.log, getChannel());
if (options.configureHttpExecutor) {
// electron-updater has no public executor hook; keep the macOS cURL override localized.
updater.httpExecutor = options.configureHttpExecutor();
}
if (options.disableDifferentialDownload !== undefined) {
updater.disableDifferentialDownload = options.disableDifferentialDownload;
}
let nativeUpdaterSupported = null;
async function getNativeUpdaterSupported() {
if (!options.isNativeUpdaterSupported)
return true;
if (nativeUpdaterSupported === null) {
nativeUpdaterSupported = Promise.resolve(options.isNativeUpdaterSupported());
}
return nativeUpdaterSupported;
}
return {
async checkForUpdates(channel) {
if (!options.isPackaged) {
return {
available: false,
version: options.currentVersion,
canUpdate: false,
};
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping native app update check because native updater is unsupported.');
return {
available: false,
version: options.currentVersion,
canUpdate: false,
};
}
configureAutoUpdater(updater, options.log, channel ?? getChannel());
const result = await updater.checkForUpdates();
const version = result?.updateInfo?.version ?? options.currentVersion;
return {
available: (0, release_assets_1.compareSemverLike)(version, options.currentVersion) > 0,
version,
canUpdate: true,
};
},
async downloadUpdate() {
if (!options.isPackaged) {
options.log('Skipping app update download because this build is not packaged.');
return;
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping app update download because native updater is unsupported.');
return;
}
await updater.downloadUpdate();
},
async quitAndInstall() {
if (!options.isPackaged) {
options.log('Skipping app update install because this build is not packaged.');
return;
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping app update install because native updater is unsupported.');
return;
}
updater.quitAndInstall(false, true);
},
};
}
//# sourceMappingURL=app-updater.js.map
+11
View File
@@ -15,6 +15,7 @@
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"vscode-json-languageservice": "^5.7.2",
"ws": "^8.19.0",
},
"devDependencies": {
@@ -188,6 +189,8 @@
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
@@ -722,6 +725,14 @@
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
"vscode-json-languageservice": ["vscode-json-languageservice@5.7.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w=="],
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
@@ -0,0 +1,4 @@
type: fixed
area: character-dictionary
- Reused cached character-dictionary media matches so loading a title with an existing snapshot no longer sends another AniList search request.
+6
View File
@@ -0,0 +1,6 @@
type: added
area: config
- Added a dedicated Configuration window with launcher entry points via `subminer --config` and `subminer config`.
- Fixed the Configuration window preload so launcher-opened windows can initialize even when Electron sandboxing is active.
- Kept config-window startup lightweight by skipping AniList token refresh and automatic update polling.
@@ -0,0 +1,6 @@
type: fixed
area: overlay
- Controller config and debug shortcuts now stay closed while controller support is disabled and show a notice to enable `controller.enabled` manually.
- Controller binding rows now start learn mode from the edit pencil, so clicking edit and pressing a controller button saves the remap.
- Controller remaps are now saved per controller profile, binding badges also start learn mode, and row reset buttons restore individual bindings to their defaults.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: docs
- Published stable docs at the site root with current development docs under `/main/`.
+6
View File
@@ -0,0 +1,6 @@
type: fixed
area: anilist
- Used fresh mpv time-position, duration, and subtitle timing events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold.
- Prefer season-specific AniList search results for multi-season files before falling back to the base title.
- Show a clear AniList message when the matched season is not in Planning or Watching instead of silently queueing an impossible progress update.
+10
View File
@@ -0,0 +1,10 @@
type: fixed
area: overlay
- Hid the macOS visible overlay when mpv is no longer the foreground target so other apps and Spaces are not covered by SubMiner subtitles.
- Kept the macOS overlay layered above active mpv while stats mouse passthrough is enabled, and treated the frontmost mpv app as the focus signal.
- Opened the stats overlay inactive on macOS so it appears over fullscreen mpv instead of switching back to SubMiner's original desktop.
- Preserved the active mpv focus state through transient macOS helper misses so subtitles do not flicker while mpv remains foreground.
- Kept fullscreen macOS overlays stable when mpv remains frontmost but window geometry temporarily disappears from the macOS window APIs.
- Released the macOS overlay when the helper reports mpv is no longer foreground so other apps are no longer covered.
- Reduced macOS window-tracker background work by preferring the compiled helper and slowing polls while mpv is stably focused.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: docs
- Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests targets under the current archive path, local dev version routes serve warmed archive files instead of redirecting to production or falling through to VitePress 404s, and internal README files do not break archived builds.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: launcher
- Added `subminer --version` and `subminer -v` to print the installed SubMiner app version.
+3 -1
View File
@@ -1,4 +1,6 @@
type: fixed
area: updates
- Avoided native `electron-updater` checks where they are unsafe, so tray and background update checks continue through GitHub release metadata without crashing the app.
- Restored the standard macOS `electron-updater`/Squirrel update path and routed supplemental GitHub updater requests through Electron networking instead of Node fetch.
- macOS update checks now skip local build-output apps outside Applications before touching Squirrel, and macOS tray checks no longer perform the supplemental GitHub asset lookup.
- macOS `electron-updater` metadata and full ZIP downloads now use `/usr/bin/curl` under the hood to avoid the Electron network crash seen during tray update checks while preserving Squirrel installation.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: release
- Prerelease note generation now reuses existing reviewed prerelease notes and asks Claude to merge only new fragment material, while `make clean` preserves `release/prerelease-notes.md`.
+61 -60
View File
@@ -10,7 +10,7 @@
// Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
// ==========================================
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
"auto_start_overlay": false, // Auto-start the subtitle overlay window when SubMiner launches. Values: true | false
// ==========================================
// Texthooker Server
@@ -18,7 +18,7 @@
// ==========================================
"texthooker": {
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
"openBrowser": false // Open browser setting. Values: true | false
"openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false
}, // Configure texthooker startup launch and browser opening behavior.
// ==========================================
@@ -138,7 +138,8 @@
"axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ==========================================
@@ -173,24 +174,24 @@
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
// ==========================================
"shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
"triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
"triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
"mineSentence": "CommandOrControl+S", // Mine sentence setting.
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.
"copySubtitle": "CommandOrControl+C", // Accelerator that copies the current subtitle line to the clipboard.
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.
"updateLastCardFromClipboard": "CommandOrControl+V", // Accelerator that updates the last mined Anki card using the current clipboard contents.
"triggerFieldGrouping": "CommandOrControl+G", // Accelerator that triggers Kiku field grouping on duplicate cards.
"triggerSubsync": "Ctrl+Alt+S", // Accelerator that triggers subsync against the active subtitle file.
"mineSentence": "CommandOrControl+S", // Accelerator that mines the current sentence as a new Anki card.
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Accelerator that mines consecutive sentences while the multi-mine window stays open.
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
"openSessionHelp": "CommandOrControl+Slash", // Open session help setting.
"openControllerSelect": "Alt+C", // Open controller select setting.
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
"toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
"markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card.
"openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ==========================================
@@ -327,9 +328,9 @@
// Hot-reload: defaultMode updates live while SubMiner is running.
// ==========================================
"secondarySub": {
"secondarySubLanguages": [], // Secondary sub languages setting.
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
"defaultMode": "hover" // Default mode setting.
"secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available.
"autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false
"defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover
}, // Dual subtitle track options.
// ==========================================
@@ -338,9 +339,9 @@
// ==========================================
"subsync": {
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
"alass_path": "", // Alass path setting.
"ffsubsync_path": "", // Ffsubsync path setting.
"ffmpeg_path": "", // Ffmpeg path setting.
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
"replace": true // Replace the active subtitle file when sync completes. Values: true | false
}, // Subsync engine and executable paths.
@@ -349,7 +350,7 @@
// Initial vertical subtitle position from the bottom.
// ==========================================
"subtitlePosition": {
"yPercent": 10 // Y percent setting.
"yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.
}, // Initial vertical subtitle position from the bottom.
// ==========================================
@@ -453,9 +454,9 @@
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
"model": "openai/gpt-4o-mini", // Model setting.
"model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider.
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests.
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests.
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
@@ -468,7 +469,7 @@
// ==========================================
"ankiConnect": {
"enabled": true, // Enable AnkiConnect integration. Values: true | false
"url": "http://127.0.0.1:8765", // Url setting.
"url": "http://127.0.0.1:8765", // Base URL of the AnkiConnect HTTP server.
"pollingRate": 3000, // Polling interval in milliseconds.
"proxy": {
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
@@ -481,11 +482,11 @@
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"fields": {
"word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Audio setting.
"image": "Picture", // Image setting.
"sentence": "Sentence", // Sentence setting.
"miscInfo": "MiscInfo", // Misc info setting.
"translation": "SelectionText" // Translation setting.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
"image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text.
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
"translation": "SelectionText" // Card field that receives the current selection or translated text.
}, // Fields setting.
"ai": {
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
@@ -493,18 +494,18 @@
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows.
}, // Ai setting.
"media": {
"generateAudio": true, // Generate audio setting. Values: true | false
"generateImage": true, // Generate image setting. Values: true | false
"imageType": "static", // Image type setting.
"imageFormat": "jpg", // Image format setting.
"imageQuality": 92, // Image quality setting.
"animatedFps": 10, // Animated fps setting.
"animatedMaxWidth": 640, // Animated max width setting.
"animatedCrf": 35, // Animated crf setting.
"generateAudio": true, // Generate sentence audio for mined cards. Values: true | false
"generateImage": true, // Generate screenshot or animated image for mined cards. Values: true | false
"imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif
"imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp
"imageQuality": 92, // Quality (0-100) used for lossy static image encoders.
"animatedFps": 10, // Target frame rate for animated AVIF captures.
"animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures.
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"audioPadding": 0.5, // Audio padding setting.
"fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30 // Max media duration setting.
"audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
}, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
@@ -515,11 +516,11 @@
"color": "#a6da95" // Color used for known-word highlights.
}, // Known words setting.
"behavior": {
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
"overwriteImage": true, // Overwrite image setting. Values: true | false
"mediaInsertMode": "append", // Media insert mode setting.
"highlightWord": true, // Highlight word setting. Values: true | false
"notificationType": "osd", // Notification type setting.
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting.
"nPlusOne": {
@@ -527,16 +528,16 @@
"nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
}, // N plus one setting.
"metadata": {
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
}, // Metadata setting.
"isLapis": {
"enabled": false, // Enabled setting. Values: true | false
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
"sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards.
}, // Is lapis setting.
"isKiku": {
"enabled": false, // Enabled setting. Values: true | false
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
} // Is kiku setting.
}, // Automatic Anki updates and media generation options.
@@ -545,7 +546,7 @@
// Jimaku API configuration and defaults.
// ==========================================
"jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
"maxEntryResults": 10 // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults.
@@ -617,9 +618,9 @@
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", // Client name setting.
"clientVersion": "0.1.0", // Client version setting.
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
+364 -74
View File
@@ -1,3 +1,7 @@
import { existsSync, readFileSync, statSync } from 'node:fs';
import { extname, join, posix, resolve, sep } from 'node:path';
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com';
const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js';
@@ -7,96 +11,280 @@ const PLAUSIBLE_INIT_SCRIPT = [
`plausible.init({ endpoint: '${PLAUSIBLE_ENDPOINT}' });`,
].join('\n');
function pageToCanonicalHref(page: string): string | null {
type DocsChannel = 'stable-root' | 'stable-archive' | 'main';
type VersionManifest = {
latestStable: string;
channels: Array<{ label: string; path: string }>;
versions: Array<{ version: string; path: string }>;
};
const base = normalizeBase(process.env.SUBMINER_DOCS_BASE ?? '/');
const outDir = process.env.SUBMINER_DOCS_OUT_DIR;
const docsSourceDir = process.env.SUBMINER_DOCS_SOURCE_DIR ?? process.cwd();
const localArchiveDir = resolve(
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR ??
join(docsSourceDir, '..', '.tmp/docs-versioned-site'),
);
const channel = normalizeChannel(process.env.SUBMINER_DOCS_CHANNEL);
const docsVersion = process.env.SUBMINER_DOCS_VERSION;
const latestStable = process.env.SUBMINER_DOCS_LATEST_STABLE ?? 'v0.14.0';
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
const versionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN ?? 'production';
function normalizeBase(value: string): string {
if (!value || value === '/') return '/';
return `/${value.replace(/^\/+|\/+$/g, '')}/`;
}
function normalizeChannel(value: string | undefined): DocsChannel {
if (value === 'main' || value === 'stable-archive') return value;
return 'stable-root';
}
function parseVersionManifest(value: string | undefined): VersionManifest {
if (!value) {
return {
latestStable,
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: [{ version: latestStable, path: `/v/${latestStable.replace(/^v/, '')}/` }],
};
}
return JSON.parse(value) as VersionManifest;
}
function withDocsBase(path: string): string {
if (/^[a-z]+:\/\//i.test(path)) return path;
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
if (base === '/') return normalizedPath;
return `${base.replace(/\/$/, '')}${normalizedPath}`;
}
function pageToRoute(page: string): string | null {
if (page === '404.md') return null;
const route = page
.replace(/(^|\/)index\.md$/, '')
.replace(/\.md$/, '')
.replace(/\/$/, '');
return route ? `${DOCS_HOSTNAME}/${route}` : `${DOCS_HOSTNAME}/`;
return route ? `/${route}` : '/';
}
export default {
title: 'SubMiner Docs',
description:
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
head: [
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
[
'script',
{
async: '',
src: `${PLAUSIBLE_PROXY_HOSTNAME}${PLAUSIBLE_SITE_SCRIPT_PATH}`,
},
],
['script', {}, PLAUSIBLE_INIT_SCRIPT],
['link', { rel: 'icon', href: '/favicon.ico', sizes: 'any' }],
[
'link',
{
rel: 'icon',
type: 'image/png',
href: '/favicon-32x32.png',
sizes: '32x32',
},
],
[
'link',
{
rel: 'icon',
type: 'image/png',
href: '/favicon-16x16.png',
sizes: '16x16',
},
],
[
'link',
{
rel: 'apple-touch-icon',
href: '/apple-touch-icon.png',
sizes: '180x180',
},
],
],
appearance: 'dark',
cleanUrls: true,
metaChunk: true,
sitemap: {
hostname: DOCS_HOSTNAME,
transformItems(items) {
return items.filter(
(item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`,
);
},
},
transformHead({ page }) {
function pageToCanonicalHref(page: string): string | null {
const route = pageToRoute(page);
if (!route) return null;
if (channel === 'main') {
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
}
if (channel === 'stable-archive' && docsVersion !== latestStable) {
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
}
return route === '/' ? `${DOCS_HOSTNAME}/` : `${DOCS_HOSTNAME}${route}`;
}
function canonicalRouteWithBase(route: string): string {
const routeWithBase = withDocsBase(route);
return route === '/' ? routeWithBase : routeWithBase.replace(/\/$/, '');
}
function transformPageHead({ page }: TransformContext): HeadConfig[] {
const href = pageToCanonicalHref(page);
return href ? [['link', { rel: 'canonical', href }]] : [];
const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : [];
if (channel === 'main') {
head.push(['meta', { name: 'robots', content: 'noindex,follow' }]);
}
return head;
}
function linkToPagePath(link: string): string | null {
if (!link.startsWith('/') || link.startsWith('/v/') || link.startsWith('/main/')) {
return null;
}
const withoutHash = link.split('#')[0] ?? '/';
const withoutQuery = withoutHash.split('?')[0] ?? '/';
const route = withoutQuery.replace(/^\/+|\/+$/g, '');
return route ? `${route}.md` : 'index.md';
}
function hasPageForLink(link: string): boolean {
const pagePath = linkToPagePath(link);
if (!pagePath) return true;
return existsSync(join(docsSourceDir, pagePath));
}
function filterNav(items: DefaultTheme.NavItem[]): DefaultTheme.NavItem[] {
return items
.map((item) => {
if ('items' in item && item.items) {
return { ...item, items: filterNav(item.items as DefaultTheme.NavItem[]) };
}
if ('link' in item && item.link && !hasPageForLink(item.link)) {
return null;
}
return item;
})
.filter((item): item is DefaultTheme.NavItem => Boolean(item));
}
function filterSidebar(items: DefaultTheme.SidebarItem[]): DefaultTheme.SidebarItem[] {
return items
.map((item) => {
const filteredChildren = item.items ? filterSidebar(item.items) : undefined;
if (item.link && !hasPageForLink(item.link)) return null;
if (item.items && filteredChildren?.length === 0 && !item.link) return null;
return { ...item, items: filteredChildren };
})
.filter((item): item is DefaultTheme.SidebarItem => Boolean(item));
}
function versionSwitchLink(path: string): string {
if (/^[a-z]+:\/\//i.test(path)) return path;
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
if (versionLinkOrigin === 'local') return localVersionSwitchLink(normalizedPath);
return `${DOCS_HOSTNAME}${normalizedPath}`;
}
function localVersionSwitchLink(path: string): string {
if (base === '/') return path;
const basePath = base.replace(/\/$/, '');
const targetPath = path === '/' ? '/' : path.replace(/\/$/, '');
const relativePath = posix.relative(basePath, targetPath) || '.';
return path.endsWith('/') ? `${relativePath}/` : relativePath;
}
function shouldHandleLocalVersionRoute(pathname: string): boolean {
if (base !== '/' || channel !== 'stable-root') return false;
return /^\/main(?:\/|$)/.test(pathname) || /^\/v\/[^/]+(?:\/|$)/.test(pathname);
}
function contentTypeForPath(path: string): string {
switch (extname(path)) {
case '.css':
return 'text/css; charset=utf-8';
case '.gif':
return 'image/gif';
case '.ico':
return 'image/x-icon';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.js':
case '.mjs':
return 'text/javascript; charset=utf-8';
case '.json':
case '.jsonc':
return 'application/json; charset=utf-8';
case '.mp4':
return 'video/mp4';
case '.png':
return 'image/png';
case '.svg':
return 'image/svg+xml';
case '.ttf':
return 'font/ttf';
case '.webm':
return 'video/webm';
case '.woff':
return 'font/woff';
case '.woff2':
return 'font/woff2';
case '.xml':
return 'application/xml; charset=utf-8';
default:
return 'text/html; charset=utf-8';
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
function archiveFileForPathname(pathname: string): string | null {
if (!shouldHandleLocalVersionRoute(pathname)) return null;
const routePath = decodeURIComponent(pathname).replace(/^\/+/, '');
const filePath = resolve(localArchiveDir, routePath);
if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) {
return null;
}
const candidates = pathname.endsWith('/')
? [join(filePath, 'index.html')]
: extname(filePath)
? [filePath]
: [`${filePath}.html`, join(filePath, 'index.html')];
return candidates.find(isFile) ?? null;
}
function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean {
if (versionLinkOrigin !== 'local') return false;
const filePath = archiveFileForPathname(pathname);
if (!filePath) return false;
response.statusCode = 200;
response.setHeader('Content-Type', contentTypeForPath(filePath));
response.end(readFileSync(filePath));
return true;
}
type DevServerResponse = {
statusCode: number;
setHeader(name: string, value: string): void;
end(chunk?: string | Uint8Array): void;
};
const versionItems = [
{
text: `Latest stable (${versionManifest.latestStable})`,
link: versionSwitchLink('/'),
target: '_self',
noIcon: true,
},
lastUpdated: true,
srcExclude: ['subagents/**'],
markdown: {
theme: {
light: 'catppuccin-latte',
dark: 'catppuccin-macchiato',
},
},
themeConfig: {
logo: {
light: '/assets/SubMiner.png',
dark: '/assets/SubMiner.png',
},
siteTitle: 'SubMiner Docs',
nav: [
...versionManifest.channels
.filter((entry) => entry.label !== 'Latest stable')
.map((entry) => ({
text: entry.label,
link: versionSwitchLink(entry.path),
target: '_self',
noIcon: true,
})),
...versionManifest.versions.map((entry) => ({
text: entry.version,
link: versionSwitchLink(entry.path),
target: '_self',
noIcon: true,
})),
];
const nav: DefaultTheme.NavItem[] = [
{ text: 'Home', link: '/' },
{ text: 'Get Started', link: '/installation' },
{ text: 'Mining', link: '/mining-workflow' },
{ text: 'Configuration', link: '/configuration' },
{ text: 'Changelog', link: '/changelog' },
{ text: 'Troubleshooting', link: '/troubleshooting' },
],
sidebar: [
{ text: docsVersion ?? (channel === 'main' ? 'main' : latestStable), items: versionItems },
];
const sidebar: DefaultTheme.SidebarItem[] = [
{
text: 'Getting Started',
items: [
@@ -140,7 +328,107 @@ export default {
{ text: 'Changelog', link: '/changelog' },
],
},
];
const config: UserConfig = {
title: 'SubMiner Docs',
description:
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
base,
...(outDir ? { outDir } : {}),
vite: {
plugins: [
{
name: 'subminer-docs-local-version-redirects',
configureServer(server) {
server.middlewares.use((request, response, next) => {
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
if (serveLocalArchiveRoute(requestUrl.pathname, response)) {
return;
}
if (!shouldHandleLocalVersionRoute(requestUrl.pathname)) {
next();
return;
}
response.statusCode = 302;
response.setHeader(
'Location',
`${DOCS_HOSTNAME}${requestUrl.pathname}${requestUrl.search}`,
);
response.end();
});
},
},
],
},
head: [
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
[
'script',
{
async: '',
src: `${PLAUSIBLE_PROXY_HOSTNAME}${PLAUSIBLE_SITE_SCRIPT_PATH}`,
},
],
['script', {}, PLAUSIBLE_INIT_SCRIPT],
['link', { rel: 'icon', href: withDocsBase('/favicon.ico'), sizes: 'any' }],
[
'link',
{
rel: 'icon',
type: 'image/png',
href: withDocsBase('/favicon-32x32.png'),
sizes: '32x32',
},
],
[
'link',
{
rel: 'icon',
type: 'image/png',
href: withDocsBase('/favicon-16x16.png'),
sizes: '16x16',
},
],
[
'link',
{
rel: 'apple-touch-icon',
href: withDocsBase('/apple-touch-icon.png'),
sizes: '180x180',
},
],
],
appearance: 'dark',
cleanUrls: true,
metaChunk: true,
sitemap: {
hostname: DOCS_HOSTNAME,
transformItems(items) {
return items.filter(
(item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`,
);
},
},
transformHead: transformPageHead,
lastUpdated: true,
srcExclude: ['subagents/**', 'README.md'],
markdown: {
theme: {
light: 'catppuccin-latte',
dark: 'catppuccin-macchiato',
},
},
themeConfig: {
logo: {
light: '/assets/SubMiner.png',
dark: '/assets/SubMiner.png',
},
siteTitle: 'SubMiner Docs',
nav: filterNav(nav),
sidebar: filterSidebar(sidebar),
search: {
provider: 'local',
},
@@ -159,3 +447,5 @@ export default {
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
},
};
export default config;
@@ -1,6 +1,7 @@
<script setup>
import { useRoute, useData } from 'vitepress';
import { computed } from 'vue';
import { formatStatusLineFilePath } from '../status-line';
const route = useRoute();
const { page, frontmatter } = useData();
@@ -12,8 +13,7 @@ const mode = computed(() => {
});
const filePath = computed(() => {
const path = route.path;
return path === '/' ? 'index.md' : `${path.replace(/^\//, '')}.md`;
return formatStatusLineFilePath(route.path);
});
const section = computed(() => {
@@ -0,0 +1,16 @@
import { expect, test } from 'bun:test';
import { formatStatusLineFilePath } from './status-line';
test('status line file path formats root home as index markdown', () => {
expect(formatStatusLineFilePath('/')).toBe('index.md');
});
test('status line file path formats version archive home without trailing slash', () => {
expect(formatStatusLineFilePath('/v/0.12.0/')).toBe('v/0.12.0.md');
});
test('status line file path keeps normal docs routes as markdown files', () => {
expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe(
'v/0.12.0/configuration.md',
);
});
@@ -0,0 +1,4 @@
export function formatStatusLineFilePath(routePath: string): string {
if (routePath === '/') return 'index.md';
return `${routePath.replace(/^\/|\/$/g, '')}.md`;
}
+2 -2
View File
@@ -5,7 +5,7 @@
@font-face {
font-family: 'M PLUS 1';
src: url('/assets/fonts/Mplus1-Medium.ttf') format('truetype');
src: url('../../public/assets/fonts/Mplus1-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
@@ -13,7 +13,7 @@
@font-face {
font-family: 'Manrope Default';
src: url('/assets/fonts/manrope-latin-600-normal.ttf') format('truetype');
src: url('../../public/assets/fonts/manrope-latin-600-normal.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
+12 -5
View File
@@ -30,9 +30,16 @@ bun run docs:dev
## Cloudflare Pages
- Git repo: `ksyasuda/SubMiner`
- Root directory: `docs-site`
- Build command: `bun run docs:build`
- Build output directory: `.vitepress/dist`
- Build watch paths: `docs-site/*`
- Production branch: `main`
- Automatic production and preview deployments: disabled
- Custom domain: `docs.subminer.moe` attached to Production
- Deployment path: GitHub Actions direct upload with Wrangler
Cloudflare Pages watch paths use a single `*` wildcard for monorepo subdirectories. `docs-site/*` matches nested files under the docs site; `docs-site/**` can cause docs-only pushes to be skipped.
The public docs root is stable-only:
- `/` serves the latest stable release docs.
- `/main/` serves development docs from `main` and is marked `noindex,follow`.
- `/v/<version>/` serves stable release archives.
- Prerelease tags do not update the docs site.
Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments.
+12 -11
View File
@@ -17,8 +17,8 @@ AniList integration is opt-in. To enable it:
{
"anilist": {
"enabled": true,
"accessToken": ""
}
"accessToken": "",
},
}
```
@@ -37,8 +37,8 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
The update flow:
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
## Update Queue and Retry
@@ -46,7 +46,7 @@ The update flow:
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
| Parameter | Value |
| --- | --- |
| ---------------- | ---------- |
| Initial backoff | 30 seconds |
| Maximum backoff | 6 hours |
| Maximum attempts | 8 |
@@ -85,15 +85,15 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
"collapsibleSections": {
"description": false,
"characterInformation": false,
"voicedBy": false
}
}
}
"voicedBy": false,
},
},
},
}
```
| Option | Values | Description |
| --- | --- | --- |
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
@@ -106,7 +106,7 @@ See the [Character Dictionary](/character-dictionary) page for full details on t
## CLI Commands
| Command | Description |
| --- | --- |
| ----------------------- | ------------------------------------------------------------- |
| `--anilist-setup` | Open AniList setup/auth flow helper window |
| `--anilist-status` | Print current token resolution state and retry queue counters |
| `--anilist-logout` | Clear stored AniList token from local persisted state |
@@ -115,6 +115,7 @@ See the [Character Dictionary](/character-dictionary) page for full details on t
## Troubleshooting
- **Updates not triggering:** Confirm `anilist.enabled` is `true`. SubMiner requires at least 85% of the episode watched and a minimum of 10 minutes. Short episodes or partial watches will not trigger an update.
- **Update not possible:** Add the season to your AniList Planning or Watching list first. SubMiner will not create new AniList list entries automatically.
- **Wrong episode or title matched:** Detection quality is best when `guessit` is installed and on your `PATH`. Without it, SubMiner falls back to internal filename parsing which can be less accurate with unusual naming conventions.
- **Token issues:** Run `--anilist-status` to check token state. If the token is invalid or expired, run `--anilist-setup` or `--anilist-logout` and re-authenticate.
- **Updates failing repeatedly:** Run `--anilist-status` to see retry queue counters. Items that fail 8 times are moved to the dead-letter queue. Check network connectivity and AniList API status.
+2 -2
View File
@@ -35,7 +35,7 @@ Character dictionary sync is disabled by default. To turn it on:
```
::: tip
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot.
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
:::
::: warning
@@ -139,7 +139,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine
5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
6. **ready** — Dictionary is live. Character names will match on the next subtitle line.
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`:
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search.
```jsonc
{
+48 -9
View File
@@ -4,6 +4,10 @@ outline: [2, 3]
# Configuration
<script setup>
import { withBase } from 'vitepress';
</script>
Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
@@ -59,6 +63,28 @@ For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback
On macOS, these validation warnings also open a native dialog with full details (desktop notification banners can truncate long messages).
### Configuration Window
SubMiner also includes a dedicated **Configuration** window from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It groups settings by workflow instead of mirroring the raw config-file shape:
- Viewing
- Mining & Anki
- Playback & Sources
- Input
- Integrations
- Tracking & App
- Advanced
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Viewing** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`.
The settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies.
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
Some compatibility-only or ignored legacy keys are intentionally hidden from the normal field list, including legacy top-level Anki migration fields, old N+1 aliases, the removed YouTube subtitle-generation primary-language key, `anilist.characterDictionary.refreshTtlHours`, `anilist.characterDictionary.evictionPolicy`, `jellyfin.accessToken`, `jellyfin.userId`, and normal editing for `controller.buttonIndices`. Advanced/raw JSON editing remains the escape hatch for unsupported or legacy keys.
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
### Hot-Reload Behavior
SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically.
@@ -608,8 +634,12 @@ Important behavior:
- Controller input is only active while keyboard-only mode is enabled.
- Keyboard-only mode continues to work normally without a controller.
- By default SubMiner uses the first connected controller.
- Fresh installs keep controller support disabled until you set `controller.enabled` to `true`.
- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
- The `Alt+C` config modal and `Alt+Shift+C` debug modal stay closed while controller support is disabled.
- Click the binding badge, edit pencil, or `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
- Click the reset button beside the edit pencil to restore one binding to the built-in default.
- Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile.
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
@@ -658,6 +688,15 @@ Important behavior:
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
},
"profiles": {
"Xbox Wireless Controller": {
"label": "Xbox Wireless Controller",
"bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
"mineCard": { "kind": "button", "buttonIndex": 2 },
},
},
},
},
}
```
@@ -678,7 +717,7 @@ Default logical mapping:
- `L3`: toggle mpv pause
- `L2` / `R2`: unbound by default
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors for you, so manual edits are only needed when you want to script or copy exact mappings.
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors under `controller.profiles["<controller id>"]` for the selected controller. Manual edits are only needed when you want to script or copy exact mappings.
If you bind a discrete action to an axis manually, include `direction`:
@@ -692,15 +731,15 @@ If you bind a discrete action to an axis manually, include `direction`:
}
```
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings` or `controller.profiles.*.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
If one controller reports non-standard raw button numbers, override `controller.profiles["<controller id>"].buttonIndices` using values from the `Alt+Shift+C` debug modal. Use global `controller.buttonIndices` only when the mapping should apply to every controller without a profile.
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and profile `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
### Manual Card Update Shortcuts
@@ -1009,12 +1048,12 @@ To refresh roughly once per day, set:
`deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate.
When the manual merge popup opens, SubMiner pauses playback and closes any open Yomitan popup first so the merge flow can take focus.
<video controls playsinline preload="metadata" poster="/assets/kiku-integration-poster.jpg" style="width: 100%; max-width: 960px;">
<source :src="'/assets/kiku-integration.webm'" type="video/webm" />
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
Your browser does not support the video tag.
</video>
<a :href="'/assets/kiku-integration.webm'" target="_blank" rel="noreferrer">Open demo in a new tab</a>
<a :href="withBase('/assets/kiku-integration.webm')" target="_blank" rel="noreferrer">Open demo in a new tab</a>
## External Integrations
@@ -1216,7 +1255,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime and are hidden from the configuration window.
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
+16 -14
View File
@@ -3,6 +3,8 @@
Short recordings of SubMiner's key features and integrations from real playback sessions.
<script setup>
import { withBase } from 'vitepress';
const v = '20260301-1';
</script>
@@ -10,11 +12,11 @@ const v = '20260301-1';
Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner automatically attaches the sentence, a timing-accurate audio clip, a screenshot, and a translation.
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
<video controls playsinline preload="metadata" :poster="withBase(`/assets/minecard-poster.jpg?v=${v}`)">
<source :src="withBase(`/assets/minecard.webm?v=${v}`)" type="video/webm" />
<source :src="withBase(`/assets/minecard.mp4?v=${v}`)" type="video/mp4" />
<a :href="withBase(`/assets/minecard.webm?v=${v}`)" target="_blank" rel="noreferrer">
<img :src="withBase(`/assets/minecard.webp?v=${v}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
</a>
</video>
@@ -25,9 +27,9 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
Search and download subtitles from Jimaku, then automatically synchronize them with alass or ffsubsync — all from within SubMiner.
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/subtitle-sync-poster.jpg?v=${v}`">
<source :src="`/assets/demos/subtitle-sync.webm?v=${v}`" type="video/webm" />
<source :src="`/assets/demos/subtitle-sync.mp4?v=${v}`" type="video/mp4" />
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/subtitle-sync-poster.jpg?v=${v}`)">
<source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" />
<source :src="withBase(`/assets/demos/subtitle-sync.mp4?v=${v}`)" type="video/mp4" />
</video> -->
::: info VIDEO COMING SOON
@@ -37,9 +39,9 @@ Search and download subtitles from Jimaku, then automatically synchronize them w
Browse your Jellyfin library, cast to devices, and launch playback directly from SubMiner. Watch progress syncs back to your Jellyfin server.
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/jellyfin-poster.jpg?v=${v}`">
<source :src="`/assets/demos/jellyfin.webm?v=${v}`" type="video/webm" />
<source :src="`/assets/demos/jellyfin.mp4?v=${v}`" type="video/mp4" />
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/jellyfin-poster.jpg?v=${v}`)">
<source :src="withBase(`/assets/demos/jellyfin.webm?v=${v}`)" type="video/webm" />
<source :src="withBase(`/assets/demos/jellyfin.mp4?v=${v}`)" type="video/mp4" />
</video> -->
::: info VIDEO COMING SOON
@@ -49,9 +51,9 @@ Browse your Jellyfin library, cast to devices, and launch playback directly from
Open subtitles in an external texthooker page for use with browser-based tools and extensions alongside the overlay.
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/texthooker-poster.jpg?v=${v}`">
<source :src="`/assets/demos/texthooker.webm?v=${v}`" type="video/webm" />
<source :src="`/assets/demos/texthooker.mp4?v=${v}`" type="video/mp4" />
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/texthooker-poster.jpg?v=${v}`)">
<source :src="withBase(`/assets/demos/texthooker.webm?v=${v}`)" type="video/webm" />
<source :src="withBase(`/assets/demos/texthooker.mp4?v=${v}`)" type="video/mp4" />
</video> -->
::: info VIDEO COMING SOON
+10 -1
View File
@@ -113,6 +113,14 @@ bun run docs:test
bun run docs:build
```
For production docs routing, run the versioned build:
```bash
bun run docs:build:versioned
```
The versioned build writes `.tmp/docs-versioned-site` with latest stable docs at `/`, development docs at `/main/`, and stable archives under `/v/<version>/`. Prerelease tags are skipped. Public assets from `docs-site/public/assets` are shared from root `/assets/` so large demo media is not duplicated into every version archive; generated VitePress CSS and JS assets stay under each version route. Stale `.tmp/docs-versioned-archive-cache` generations are pruned after a successful build, and intermediate `.tmp/docs-versioned-build` workspaces are removed.
Focused commands:
```bash
@@ -154,6 +162,7 @@ bun run format:check:src
- `make pretty` runs the maintained Prettier allowlist only (`format:src`).
- `bun run format:check:src` checks the same scoped set without writing changes.
- `bun run format` remains the broad repo-wide Prettier command; use it intentionally.
## Config Generation
```bash
@@ -198,7 +207,7 @@ Use Cloudflare's single `*` wildcard syntax for watch paths. `docs-site/*` cover
Run `make help` for a full list of targets. Key ones:
| Target | Description |
| ---------------------- | ---------------------------------------------------------------- |
| --------------------------- | ----------------------------------------------------------------- |
| `make build` | Build platform package for detected OS |
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
+14 -4
View File
@@ -8,6 +8,7 @@ const installationContents = readFileSync(new URL('./installation.md', import.me
const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8');
const developmentContents = readFileSync(new URL('./development.md', import.meta.url), 'utf8');
const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8');
const docsPackageContents = readFileSync(new URL('./package.json', import.meta.url), 'utf8');
const ankiIntegrationContents = readFileSync(
new URL('./anki-integration.md', import.meta.url),
'utf8',
@@ -37,13 +38,13 @@ test('docs reflect current launcher and release surfaces', () => {
expect(mpvPluginContents).toContain('\\\\.\\pipe\\subminer-socket');
expect(readmeContents).toContain('Root directory: `docs-site`');
expect(readmeContents).toContain('Build output directory: `.vitepress/dist`');
expect(readmeContents).toContain('Build watch paths: `docs-site/*`');
expect(readmeContents).toContain('Automatic production and preview deployments: disabled');
expect(readmeContents).toContain('/main/');
expect(readmeContents).toContain('GitHub Actions direct upload with Wrangler');
expect(developmentContents).not.toContain('../subminer-docs');
expect(developmentContents).toContain('bun run docs:build');
expect(developmentContents).toContain('bun run docs:test');
expect(developmentContents).toContain('Build watch paths: `docs-site/*`');
expect(developmentContents).toContain('bun run docs:build:versioned');
expect(developmentContents).not.toContain('test:subtitle:dist');
expect(developmentContents).toContain('bun run build:win');
@@ -57,6 +58,15 @@ test('docs reflect current launcher and release surfaces', () => {
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
});
test('docs dev server links version navigation to local dev routes', () => {
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
expect(docsPackageContents).toContain(
'SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts',
);
expect(docsPackageContents).toContain('SUBMINER_DOCS_VERSION_LINK_ORIGIN=local');
expect(docsPackageContents).toContain('SUBMINER_DOCS_VERSION_MANIFEST');
});
test('docs changelog keeps the current minor release headings aligned with the root changelog', () => {
const docsHeadings = extractCurrentMinorHeadings(changelogContents);
expect(docsHeadings.length).toBeGreaterThan(0);
+5 -5
View File
@@ -7,18 +7,18 @@ const docsIndexContents = readFileSync(docsIndexPath, 'utf8');
test('docs demo media uses shared cache-busting asset version token', () => {
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
expect(docsIndexContents).toContain(
':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"',
':poster="withBase(`/assets/minecard-poster.jpg?v=${demoAssetVersion}`)"',
);
expect(docsIndexContents).toContain(
'<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />',
'<source :src="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" type="video/webm" />',
);
expect(docsIndexContents).toContain(
'<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />',
'<source :src="withBase(`/assets/minecard.mp4?v=${demoAssetVersion}`)" type="video/mp4" />',
);
expect(docsIndexContents).toContain(
'<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">',
'<a :href="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" target="_blank" rel="noreferrer">',
);
expect(docsIndexContents).toContain(
'<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />',
'<img :src="withBase(`/assets/minecard.webp?v=${demoAssetVersion}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />',
);
});
+7 -5
View File
@@ -86,6 +86,8 @@ features:
---
<script setup>
import { withBase } from 'vitepress';
const demoAssetVersion = '20260223-2';
</script>
@@ -135,11 +137,11 @@ const demoAssetVersion = '20260223-2';
<span class="demo-window__dot"></span>
<span class="demo-window__title">subminer -- playback</span>
</div>
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
<video controls playsinline preload="metadata" :poster="withBase(`/assets/minecard-poster.jpg?v=${demoAssetVersion}`)">
<source :src="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" type="video/webm" />
<source :src="withBase(`/assets/minecard.mp4?v=${demoAssetVersion}`)" type="video/mp4" />
<a :href="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" target="_blank" rel="noreferrer">
<img :src="withBase(`/assets/minecard.webp?v=${demoAssetVersion}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
</a>
</video>
</div>
+4 -2
View File
@@ -174,9 +174,9 @@ subminer -u
subminer --update
```
SubMiner verifies launcher and Linux rofi theme downloads against `SHA256SUMS.txt`. If the launcher is installed in a protected path such as `/usr/local/bin/subminer`, SubMiner does not elevate itself; it shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead.
SubMiner verifies AppImage, launcher, and Linux rofi theme downloads against `SHA256SUMS.txt`. If the AppImage or launcher is installed in a protected path, SubMiner does not elevate itself; it shows the exact sudo command to run instead.
On Linux, `subminer -u` performs this update from the launcher process, so it does not need to start or IPC into the tray app.
On Linux, `subminer -u` performs the AppImage update from the launcher process, so it does not need to start or IPC into the tray app.
### From Source
@@ -206,6 +206,8 @@ Download the **DMG** artifact from [GitHub Releases](https://github.com/ksyasuda
A **ZIP** artifact is also available as a fallback — unzip and drag `SubMiner.app` into `/Applications`.
After the first updater-enabled install, tray update checks can update the macOS app automatically through Electron's standard macOS updater. The updater uses the release ZIP as its payload even when the DMG remains the normal first-install artifact.
Install dependencies using Homebrew:
```bash
+1
View File
@@ -99,6 +99,7 @@ Use `subminer <subcommand> -h` for command-specific help.
| `-r, --recursive` | Search directories recursively |
| `-R, --rofi` | Use rofi instead of fzf |
| `--setup` | Open first-run setup popup manually |
| `-v, --version` | Print installed SubMiner version |
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
| `--start` | Explicitly start overlay after mpv launches |
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
+1 -1
View File
@@ -65,7 +65,7 @@ With a gamepad connected and keyboard-only mode enabled, the full mining loop wo
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
After controller support is enabled, the controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
+2 -2
View File
@@ -5,10 +5,10 @@
"description": "In-repo VitePress documentation site for SubMiner",
"packageManager": "bun@1.3.5",
"scripts": {
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts"
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
},
"dependencies": {
"@catppuccin/vitepress": "^0.1.2",
+32
View File
@@ -4,9 +4,11 @@ import { readFileSync } from 'node:fs';
const docsConfigPath = new URL('./.vitepress/config.ts', import.meta.url);
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
const docsPackagePath = new URL('./package.json', import.meta.url);
const versionedBuildPath = new URL('../scripts/build-versioned-docs.ts', import.meta.url);
const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
const docsPackageContents = readFileSync(docsPackagePath, 'utf8');
const versionedBuildContents = readFileSync(versionedBuildPath, 'utf8');
test('docs site loads the docs.subminer.moe Plausible script through the analytics proxy', () => {
expect(docsConfigContents).toContain("const DOCS_HOSTNAME = 'https://docs.subminer.moe'");
@@ -34,3 +36,33 @@ test('docs site loads the docs.subminer.moe Plausible script through the analyti
expect(docsThemeContents).not.toContain('initPlausibleTracker');
expect(docsPackageContents).not.toContain('@plausible-analytics/tracker');
});
test('versioned docs reuse current VitePress internals for old page snapshots', () => {
expect(versionedBuildContents).toContain("cpSync(join(currentDocsSite, '.vitepress')");
expect(versionedBuildContents).toContain('overlayCurrentVitePress(snapshotDocsSite)');
});
test('versioned docs build reports archive cache hits and rebuilds', () => {
expect(versionedBuildContents).toContain(
'console.info(`[docs] archive cache key ${archiveCacheKey.slice(0, 12)}`)',
);
expect(versionedBuildContents).toContain('console.info(`[docs] cache hit ${version}`)');
expect(versionedBuildContents).toContain('console.info(`[docs] rebuilding archive ${version}`)');
});
test('versioned docs build deduplicates public assets and prunes stale workspaces', () => {
expect(versionedBuildContents).toContain('dedupeVersionedPublicAssets({');
expect(versionedBuildContents).toContain('pruneArchiveCacheGenerations({');
expect(versionedBuildContents).toContain('rmSync(buildRoot, { recursive: true, force: true });');
});
test('versioned docs archive cache key ignores generated and test-only files', () => {
expect(versionedBuildContents).toContain('isSharedInternalsHashIgnoredPath(path)');
expect(versionedBuildContents).toContain('|| /\\.test\\.[cm]?[jt]s$/.test(path)');
expect(versionedBuildContents).toContain('process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN');
expect(versionedBuildContents).not.toContain('hash.update(String(stat.mode))');
});
test('docs builds exclude the internal README from VitePress page entries', () => {
expect(docsConfigContents).toContain("srcExclude: ['subagents/**', 'README.md']");
});
+61 -60
View File
@@ -10,7 +10,7 @@
// Overlay Auto-Start
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
// ==========================================
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
"auto_start_overlay": false, // Auto-start the subtitle overlay window when SubMiner launches. Values: true | false
// ==========================================
// Texthooker Server
@@ -18,7 +18,7 @@
// ==========================================
"texthooker": {
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
"openBrowser": false // Open browser setting. Values: true | false
"openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false
}, // Configure texthooker startup launch and browser opening behavior.
// ==========================================
@@ -138,7 +138,8 @@
"axisIndex": 4, // Raw axis index captured for this analog controller action.
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
// ==========================================
@@ -173,24 +174,24 @@
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
// ==========================================
"shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
"triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
"triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
"mineSentence": "CommandOrControl+S", // Mine sentence setting.
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.
"copySubtitle": "CommandOrControl+C", // Accelerator that copies the current subtitle line to the clipboard.
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.
"updateLastCardFromClipboard": "CommandOrControl+V", // Accelerator that updates the last mined Anki card using the current clipboard contents.
"triggerFieldGrouping": "CommandOrControl+G", // Accelerator that triggers Kiku field grouping on duplicate cards.
"triggerSubsync": "Ctrl+Alt+S", // Accelerator that triggers subsync against the active subtitle file.
"mineSentence": "CommandOrControl+S", // Accelerator that mines the current sentence as a new Anki card.
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Accelerator that mines consecutive sentences while the multi-mine window stays open.
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
"openSessionHelp": "CommandOrControl+Slash", // Open session help setting.
"openControllerSelect": "Alt+C", // Open controller select setting.
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
"toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
"markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card.
"openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ==========================================
@@ -327,9 +328,9 @@
// Hot-reload: defaultMode updates live while SubMiner is running.
// ==========================================
"secondarySub": {
"secondarySubLanguages": [], // Secondary sub languages setting.
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
"defaultMode": "hover" // Default mode setting.
"secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available.
"autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false
"defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover
}, // Dual subtitle track options.
// ==========================================
@@ -338,9 +339,9 @@
// ==========================================
"subsync": {
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
"alass_path": "", // Alass path setting.
"ffsubsync_path": "", // Ffsubsync path setting.
"ffmpeg_path": "", // Ffmpeg path setting.
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
"replace": true // Replace the active subtitle file when sync completes. Values: true | false
}, // Subsync engine and executable paths.
@@ -349,7 +350,7 @@
// Initial vertical subtitle position from the bottom.
// ==========================================
"subtitlePosition": {
"yPercent": 10 // Y percent setting.
"yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.
}, // Initial vertical subtitle position from the bottom.
// ==========================================
@@ -453,9 +454,9 @@
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
"model": "openai/gpt-4o-mini", // Model setting.
"model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider.
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests.
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests.
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
@@ -468,7 +469,7 @@
// ==========================================
"ankiConnect": {
"enabled": true, // Enable AnkiConnect integration. Values: true | false
"url": "http://127.0.0.1:8765", // Url setting.
"url": "http://127.0.0.1:8765", // Base URL of the AnkiConnect HTTP server.
"pollingRate": 3000, // Polling interval in milliseconds.
"proxy": {
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
@@ -481,11 +482,11 @@
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
"fields": {
"word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Audio setting.
"image": "Picture", // Image setting.
"sentence": "Sentence", // Sentence setting.
"miscInfo": "MiscInfo", // Misc info setting.
"translation": "SelectionText" // Translation setting.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
"image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text.
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
"translation": "SelectionText" // Card field that receives the current selection or translated text.
}, // Fields setting.
"ai": {
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
@@ -493,18 +494,18 @@
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows.
}, // Ai setting.
"media": {
"generateAudio": true, // Generate audio setting. Values: true | false
"generateImage": true, // Generate image setting. Values: true | false
"imageType": "static", // Image type setting.
"imageFormat": "jpg", // Image format setting.
"imageQuality": 92, // Image quality setting.
"animatedFps": 10, // Animated fps setting.
"animatedMaxWidth": 640, // Animated max width setting.
"animatedCrf": 35, // Animated crf setting.
"generateAudio": true, // Generate sentence audio for mined cards. Values: true | false
"generateImage": true, // Generate screenshot or animated image for mined cards. Values: true | false
"imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif
"imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp
"imageQuality": 92, // Quality (0-100) used for lossy static image encoders.
"animatedFps": 10, // Target frame rate for animated AVIF captures.
"animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures.
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
"audioPadding": 0.5, // Audio padding setting.
"fallbackDuration": 3, // Fallback duration setting.
"maxMediaDuration": 30 // Max media duration setting.
"audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
}, // Media setting.
"knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
@@ -515,11 +516,11 @@
"color": "#a6da95" // Color used for known-word highlights.
}, // Known words setting.
"behavior": {
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
"overwriteImage": true, // Overwrite image setting. Values: true | false
"mediaInsertMode": "append", // Media insert mode setting.
"highlightWord": true, // Highlight word setting. Values: true | false
"notificationType": "osd", // Notification type setting.
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
}, // Behavior setting.
"nPlusOne": {
@@ -527,16 +528,16 @@
"nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
}, // N plus one setting.
"metadata": {
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
}, // Metadata setting.
"isLapis": {
"enabled": false, // Enabled setting. Values: true | false
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
"sentenceCardModel": "Japanese sentences" // Note type name used by Lapis sentence cards.
}, // Is lapis setting.
"isKiku": {
"enabled": false, // Enabled setting. Values: true | false
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
} // Is kiku setting.
}, // Automatic Anki updates and media generation options.
@@ -545,7 +546,7 @@
// Jimaku API configuration and defaults.
// ==========================================
"jimaku": {
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
"maxEntryResults": 10 // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults.
@@ -617,9 +618,9 @@
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", // Client name setting.
"clientVersion": "0.1.0", // Client version setting.
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
+391
View File
@@ -1,7 +1,13 @@
import { expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { TransformContext } from 'vitepress';
import docsConfig from './.vitepress/config';
const docsSiteDir = fileURLToPath(new URL('.', import.meta.url));
function makeTransformContext(page: string): TransformContext {
return {
page,
@@ -31,6 +37,391 @@ test('docs pages emit stable self-referential canonical URLs', async () => {
expect(JSON.stringify(rootHead).toLowerCase()).not.toContain('noindex');
});
test('main docs canonical uses /main/ and emits noindex', async () => {
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
const previousBase = process.env.SUBMINER_DOCS_BASE;
process.env.SUBMINER_DOCS_CHANNEL = 'main';
process.env.SUBMINER_DOCS_BASE = '/main/';
const { default: mainDocsConfig } = await import('./.vitepress/config?main-docs');
const head = await mainDocsConfig.transformHead?.(makeTransformContext('usage.md'));
const rootHead = await mainDocsConfig.transformHead?.(makeTransformContext('index.md'));
expect(head).toContainEqual([
'link',
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/usage' },
]);
expect(rootHead).toContainEqual([
'link',
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/' },
]);
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase;
});
test('latest stable archive canonical points to root equivalent', async () => {
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
const previousBase = process.env.SUBMINER_DOCS_BASE;
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
process.env.SUBMINER_DOCS_BASE = '/v/0.14.0/';
process.env.SUBMINER_DOCS_VERSION = 'v0.14.0';
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
const { default: latestArchiveConfig } = await import('./.vitepress/config?latest-archive');
const head = await latestArchiveConfig.transformHead?.(makeTransformContext('usage.md'));
expect(head).toContainEqual([
'link',
{ rel: 'canonical', href: 'https://docs.subminer.moe/usage' },
]);
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase;
process.env.SUBMINER_DOCS_VERSION = previousVersion;
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
});
test('stable archive theme links stay on the selected version', async () => {
const previousCwd = process.cwd();
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
const previousBase = process.env.SUBMINER_DOCS_BASE;
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
process.chdir(docsSiteDir);
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
process.env.SUBMINER_DOCS_BASE = '/v/0.12.0/';
process.env.SUBMINER_DOCS_VERSION = 'v0.12.0';
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'production';
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
latestStable: 'v0.14.0',
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: [
{ version: 'v0.14.0', path: '/v/0.14.0/' },
{ version: 'v0.12.0', path: '/v/0.12.0/' },
],
});
try {
const { default: archiveConfig } = await import('./.vitepress/config?stable-archive-links');
const nav = archiveConfig.themeConfig?.nav as Array<{
text: string;
link?: string;
items?: Array<{ text: string; link: string }>;
}>;
const sidebar = archiveConfig.themeConfig?.sidebar as Array<{
text: string;
items?: Array<{ text: string; link: string }>;
}>;
const configurationNav = nav.find((item) => item.text === 'Configuration');
const versionNav = nav.find((item) => item.text === 'v0.12.0');
const referenceSidebar = sidebar.find((item) => item.text === 'Reference');
const configurationSidebar = referenceSidebar?.items?.find(
(item) => item.text === 'Configuration',
);
expect(configurationNav?.link).toBe('/configuration');
expect(configurationSidebar?.link).toBe('/configuration');
expect(versionNav?.items).toContainEqual({
text: 'Latest stable (v0.14.0)',
link: 'https://docs.subminer.moe/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'main',
link: 'https://docs.subminer.moe/main/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'v0.14.0',
link: 'https://docs.subminer.moe/v/0.14.0/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'v0.12.0',
link: 'https://docs.subminer.moe/v/0.12.0/',
target: '_self',
noIcon: true,
});
expect(archiveConfig.themeConfig?.logo).toEqual({
light: '/assets/SubMiner.png',
dark: '/assets/SubMiner.png',
});
} finally {
process.chdir(previousCwd);
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase;
process.env.SUBMINER_DOCS_VERSION = previousVersion;
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
}
});
test('local stable archive version links stay on the dev server', async () => {
const previousCwd = process.cwd();
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
const previousBase = process.env.SUBMINER_DOCS_BASE;
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
process.chdir(docsSiteDir);
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
process.env.SUBMINER_DOCS_BASE = '/v/0.10.0/';
process.env.SUBMINER_DOCS_VERSION = 'v0.10.0';
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
latestStable: 'v0.14.0',
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: [
{ version: 'v0.14.0', path: '/v/0.14.0/' },
{ version: 'v0.10.0', path: '/v/0.10.0/' },
],
});
try {
const { default: archiveConfig } = await import('./.vitepress/config?local-archive-links');
const nav = archiveConfig.themeConfig?.nav as Array<{
text: string;
items?: Array<{ text: string; link: string }>;
}>;
const versionNav = nav.find((item) => item.text === 'v0.10.0');
expect(versionNav?.items).toContainEqual({
text: 'Latest stable (v0.14.0)',
link: '../../',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'main',
link: '../../main/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'v0.14.0',
link: '../0.14.0/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'v0.10.0',
link: './',
target: '_self',
noIcon: true,
});
} finally {
process.chdir(previousCwd);
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase;
process.env.SUBMINER_DOCS_VERSION = previousVersion;
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
}
});
test('dev docs version links use local targets for version route testing', async () => {
const previousCwd = process.cwd();
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
const previousBase = process.env.SUBMINER_DOCS_BASE;
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
process.chdir(docsSiteDir);
delete process.env.SUBMINER_DOCS_CHANNEL;
delete process.env.SUBMINER_DOCS_BASE;
delete process.env.SUBMINER_DOCS_VERSION;
delete process.env.SUBMINER_DOCS_LATEST_STABLE;
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
latestStable: 'v0.14.0',
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: [
{ version: 'v0.14.0', path: '/v/0.14.0/' },
{ version: 'v0.12.0', path: '/v/0.12.0/' },
{ version: 'v0.11.2', path: '/v/0.11.2/' },
],
});
try {
const { default: devConfig } = await import('./.vitepress/config?dev-version-links');
const nav = devConfig.themeConfig?.nav as Array<{
text: string;
items?: Array<{ text: string; link: string }>;
}>;
const versionNav = nav.find((item) => item.text === 'v0.14.0');
expect(versionNav?.items).toContainEqual({
text: 'Latest stable (v0.14.0)',
link: '/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'main',
link: '/main/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'v0.12.0',
link: '/v/0.12.0/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items?.map((item) => item.text)).toEqual([
'Latest stable (v0.14.0)',
'main',
'v0.14.0',
'v0.12.0',
'v0.11.2',
]);
} finally {
process.chdir(previousCwd);
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase;
process.env.SUBMINER_DOCS_VERSION = previousVersion;
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
}
});
test('dev server redirects unserved version routes to production docs', () => {
let routeHandler:
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
| undefined;
const fakeServer = {
middlewares: {
use(handler: typeof routeHandler) {
routeHandler = handler;
},
},
};
const plugins = Array.isArray(docsConfig.vite?.plugins)
? docsConfig.vite.plugins
: [docsConfig.vite?.plugins].filter(Boolean);
const redirectPlugin = plugins.find(
(plugin): plugin is { name: string; configureServer: (server: never) => void } =>
Boolean(plugin) &&
typeof plugin === 'object' &&
'name' in plugin &&
plugin.name === 'subminer-docs-local-version-redirects' &&
'configureServer' in plugin,
);
expect(redirectPlugin).toBeDefined();
redirectPlugin?.configureServer(fakeServer as never);
const response = new DevRedirectResponse();
let nextCalled = false;
routeHandler?.({ url: '/v/0.14.0/?from=dev' }, response, () => {
nextCalled = true;
});
expect(nextCalled).toBe(false);
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe('https://docs.subminer.moe/v/0.14.0/?from=dev');
const rootResponse = new DevRedirectResponse();
routeHandler?.({ url: '/configuration' }, rootResponse, () => {
nextCalled = true;
});
expect(rootResponse.ended).toBe(false);
expect(nextCalled).toBe(true);
});
test('dev server serves local archive files for local version links', async () => {
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
const previousArchiveDir = process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR;
const archiveDir = mkdtempSync(join(tmpdir(), 'subminer-docs-archive-'));
mkdirSync(join(archiveDir, 'v/0.14.0'), { recursive: true });
writeFileSync(join(archiveDir, 'v/0.14.0/index.html'), '<h1>local archive</h1>');
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir;
try {
const { default: localDevConfig } = await import('./.vitepress/config?local-dev-redirects');
let routeHandler:
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
| undefined;
const fakeServer = {
middlewares: {
use(handler: typeof routeHandler) {
routeHandler = handler;
},
},
};
const plugins = Array.isArray(localDevConfig.vite?.plugins)
? localDevConfig.vite.plugins
: [localDevConfig.vite?.plugins].filter(Boolean);
const redirectPlugin = plugins.find(
(plugin): plugin is { name: string; configureServer: (server: never) => void } =>
Boolean(plugin) &&
typeof plugin === 'object' &&
'name' in plugin &&
plugin.name === 'subminer-docs-local-version-redirects' &&
'configureServer' in plugin,
);
redirectPlugin?.configureServer(fakeServer as never);
const response = new DevRedirectResponse();
let nextCalled = false;
routeHandler?.({ url: '/v/0.14.0/?from=dev' }, response, () => {
nextCalled = true;
});
expect(nextCalled).toBe(false);
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toBe('text/html; charset=utf-8');
expect(response.headers.location).toBeUndefined();
expect(response.body).toBe('<h1>local archive</h1>');
} finally {
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = previousArchiveDir;
rmSync(archiveDir, { recursive: true, force: true });
}
});
class DevRedirectResponse {
statusCode = 200;
headers: Record<string, string> = {};
ended = false;
body = '';
setHeader(name: string, value: string) {
this.headers[name.toLowerCase()] = value;
}
end(chunk?: string | Uint8Array) {
if (chunk) {
this.body = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
}
this.ended = true;
}
}
test('docs sitemap excludes duplicate README page from indexable URLs', async () => {
const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }];
+9 -6
View File
@@ -78,6 +78,8 @@ subminer -S video.mkv # Same as above via --start-overlay
subminer https://youtu.be/... # Play a YouTube URL
subminer ytsearch:"jp news" # Play first YouTube search result
subminer --setup # Open first-run setup popup
subminer --version # Print installed SubMiner version
subminer -v # Same as above
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
subminer --log-level warn video.mkv # Set logging level explicitly
subminer --args '--fs=opengl-hq --ytdl-format=bestvideo*+bestaudio/best' video.mkv # Pass extra mpv args
@@ -283,13 +285,14 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
### Getting Started
1. Connect a controller before or after launching SubMiner.
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
2. Set `controller.enabled` to `true` in your config.
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
4. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
5. Click the binding badge, edit pencil, or `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
6. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
7. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
By default SubMiner uses the first connected controller after controller support is enabled. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline per controller. The reset button beside each edit pencil restores that binding to its built-in default for the selected controller. `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both modals stay closed while `controller.enabled` is false, and both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
### Default Button Mapping
@@ -316,7 +319,7 @@ By default SubMiner uses the first connected controller. `Alt+C` opens the contr
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
All button and axis mappings are configurable under the `controller` config block. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
## Keybindings
+14 -4
View File
@@ -37,6 +37,7 @@
8. If `docs-site/` changed, also run:
`bun run docs:test`
`bun run docs:build`
`bun run docs:build:versioned`
9. Commit release prep.
10. Tag the commit: `git tag v<version>`.
11. Push commit + tag.
@@ -57,12 +58,16 @@
`*.yml` and `*.blockmap` files under `release/`.
5. Commit the prerelease prep (package.json version bump + the generated
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
committed file — so review it before committing. Do not run
`bun run changelog:build`.
committed file — so review it before committing. If you add more
`changes/*.md` fragments for a later beta/RC, rerun
`bun run changelog:prerelease-notes --version <version>`; the generator uses
the existing prerelease notes as the baseline and asks Claude to merge only
the new fragment material. Do not run `bun run changelog:build`.
6. Tag the commit: `git tag v<version>`.
7. Push commit + tag.
Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes.
Prerelease tags also do not update `https://docs.subminer.moe/`.
Notes:
@@ -70,16 +75,21 @@ Notes:
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
- `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
- If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
- Stable release tags update `https://docs.subminer.moe/` and `https://docs.subminer.moe/v/<version>/` through `.github/workflows/docs-pages.yml`; `/main/` continues to show development docs from `main`.
- Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`.
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
+70
View File
@@ -0,0 +1,70 @@
# Config Settings Window
read_when: changing config UI, config save behavior, or config docs
## Intent
Add a dedicated Electron settings window for editing canonical config values without exposing the historical layout mistakes in `config.jsonc`.
The UI groups options by workflow:
- Viewing
- Mining & Anki
- Playback & Sources
- Input
- Integrations
- Tracking & App
- Advanced
Each field maps back to its current raw config path. The presentation layer must stay separate from generated config-template sections.
## Sources
- Canonical defaults: `DEFAULT_CONFIG`
- Existing option descriptions/enums: `CONFIG_OPTION_REGISTRY`
- UI registry: `src/config/settings/registry.ts`
- JSONC save path: `src/config/settings/jsonc-edit.ts`
- Window runtime: `src/main/runtime/config-settings-window.ts`
## Save Contract
Settings writes use `jsonc-parser.modify`, not `JSON.stringify`.
Required behavior:
- Preserve comments, trailing commas, unrelated keys, and hidden legacy keys.
- Reset removes the explicit path so defaults resolve normally.
- Validate the candidate config before writing.
- Reject warnings caused by modified fields.
- Preserve unrelated existing warnings and return them in the snapshot.
- Write atomically, reload `ConfigService`, classify with existing hot-reload logic, and apply live changes where supported.
- Never return secret values to the renderer; snapshots only expose configured/not-configured state.
## Hidden Compatibility Keys
Do not expose these as first-class controls:
- `ankiConnect.deck`
- Legacy top-level Anki migration fields such as `wordField`, `audioField`, media-generation aliases, and behavior aliases
- Legacy `ankiConnect.nPlusOne.*` aliases except canonical `nPlusOne.nPlusOne` and `nPlusOne.minSentenceWords`
- Deprecated Lapis sentence-card fields
- `youtubeSubgen.primarySubLanguages`
- `anilist.characterDictionary.refreshTtlHours`
- `anilist.characterDictionary.evictionPolicy`
- `jellyfin.accessToken`
- `jellyfin.userId`
- `controller.buttonIndices` as a normal editable field
## Verification
Minimum targeted checks:
- `bun test src/config/settings/registry.test.ts src/config/settings/jsonc-edit.test.ts src/settings/settings-model.test.ts src/main/runtime/config-settings-window.test.ts`
- `bun run test:config`
- `bun run typecheck`
- `bun run build`
If docs changed:
- `bun run docs:test`
- `bun run docs:build`
+8 -1
View File
@@ -3,7 +3,14 @@ import type { LauncherCommandContext } from './context.js';
export function runAppPassthroughCommand(context: LauncherCommandContext): boolean {
const { args, appPath } = context;
if (!args.appPassthrough || !appPath) {
if (!appPath) {
return false;
}
if (args.configSettings) {
runAppCommandWithInherit(appPath, ['--config']);
return true;
}
if (!args.appPassthrough) {
return false;
}
runAppCommandWithInherit(appPath, args.appArgs);
@@ -52,6 +52,8 @@ function createContext(): LauncherCommandContext {
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
version: false,
configSettings: false,
configPath: false,
configShow: false,
mpvIdle: false,
+35
View File
@@ -159,6 +159,41 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
assert.equal(parsed.logLevel, 'warn');
});
test('applyInvocationsToArgs maps bare config invocation to settings window', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: {
action: undefined,
},
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
});
assert.equal(parsed.configSettings, true);
assert.equal(parsed.configPath, false);
});
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
const parsed = createDefaultArgs({});
+7 -2
View File
@@ -156,7 +156,9 @@ export function createDefaultArgs(
statsCleanupLifetime: false,
doctor: false,
doctorRefreshKnownWords: false,
version: false,
update: false,
configSettings: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -219,6 +221,8 @@ export function applyRootOptionsToArgs(
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
if (options.rofi === true) parsed.useRofi = true;
if (options.update === true) parsed.update = true;
if (options.version === true) parsed.version = true;
if (options.config === true) parsed.configSettings = true;
if (options.startOverlay === true) parsed.autoStartOverlay = true;
if (options.texthooker === false) parsed.useTexthooker = false;
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
@@ -306,8 +310,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
if (invocations.configInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
}
const action = (invocations.configInvocation.action || 'path').toLowerCase();
if (action === 'path') parsed.configPath = true;
const action = (invocations.configInvocation.action || '').toLowerCase();
if (!action) parsed.configSettings = true;
else if (action === 'path') parsed.configPath = true;
else if (action === 'show') parsed.configShow = true;
else fail(`Unknown config action: ${invocations.configInvocation.action}`);
}
+5 -3
View File
@@ -15,7 +15,7 @@ export interface JellyfinInvocation {
}
export interface CommandActionInvocation {
action: string;
action?: string;
logLevel?: string;
}
@@ -57,6 +57,8 @@ function applyRootOptions(program: Command): void {
.option('-p, --profile <profile>', 'MPV profile')
.option('--start', 'Explicitly start overlay')
.option('--log-level <level>', 'Log level')
.option('-v, --version', 'Show SubMiner version')
.option('--config', 'Open configuration window')
.option('-u, --update', 'Check for updates')
.option('-R, --rofi', 'Use rofi picker')
.option('-S, --start-overlay', 'Auto-start overlay')
@@ -292,9 +294,9 @@ export function parseCliPrograms(
commandProgram
.command('config')
.description('Config helpers')
.argument('[action]', 'path|show', 'path')
.argument('[action]', 'path|show')
.option('--log-level <level>', 'Log level')
.action((action: string, options: Record<string, unknown>) => {
.action((action: string | undefined, options: Record<string, unknown>) => {
configInvocation = {
action,
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
+72
View File
@@ -99,6 +99,30 @@ test('config discovery ignores lowercase subminer candidate', () => {
assert.equal(resolved, expected);
});
test('version flag prints installed app version without requiring app binary', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const result = runLauncher(['--version'], makeTestEnv(homeDir, xdgConfigHome));
assert.equal(result.status, 0);
assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/);
assert.equal(result.stderr, '');
});
});
test('short version flag prints installed app version without requiring app binary', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const result = runLauncher(['-v'], makeTestEnv(homeDir, xdgConfigHome));
assert.equal(result.status, 0);
assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/);
assert.equal(result.stderr, '');
});
});
test('config path prefers jsonc over json for same directory', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
@@ -208,6 +232,54 @@ test('doctor refresh-known-words forwards app refresh command without requiring
});
});
test('launcher config option forwards app configuration window command', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['--config'], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
});
});
test('launcher config command forwards app configuration window command', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['config'], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
});
});
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
+12
View File
@@ -1,4 +1,5 @@
import path from 'node:path';
import packageJson from '../package.json';
import {
loadLauncherJellyfinConfig,
loadLauncherMpvConfig,
@@ -20,6 +21,11 @@ import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runPlaybackCommand } from './commands/playback-command.js';
import { runUpdateCommand } from './commands/update-command.js';
const APP_VERSION =
typeof packageJson.version === 'string' && packageJson.version.trim()
? packageJson.version
: 'unknown';
function createCommandContext(
args: ReturnType<typeof parseArgs>,
scriptPath: string,
@@ -56,6 +62,12 @@ async function main(): Promise<void> {
const launcherConfig = loadLauncherYoutubeSubgenConfig();
const launcherMpvConfig = loadLauncherMpvConfig();
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig, launcherMpvConfig);
if (args.version) {
console.log(`SubMiner ${APP_VERSION}`);
return;
}
const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
const appPath = findAppBinary(scriptPath);
+2
View File
@@ -529,6 +529,8 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
version: false,
configSettings: false,
configPath: false,
configShow: false,
mpvIdle: false,
+44
View File
@@ -57,6 +57,12 @@ test('parseArgs captures mpv args string', () => {
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
});
test('parseArgs maps root config window option', () => {
const parsed = parseArgs(['--config'], 'subminer', {});
assert.equal(parsed.configSettings, true);
});
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
const shortParsed = parseArgs(['-u'], 'subminer', {});
const longParsed = parseArgs(['--update'], 'subminer', {});
@@ -69,6 +75,17 @@ test('parseArgs maps root update flags without conflicting with jellyfin usernam
assert.equal(jellyfinParsed.jellyfinUsername, 'kyle');
});
test('parseArgs maps root version flags without conflicting with stats vocab flag', () => {
const shortParsed = parseArgs(['-v'], 'subminer', {});
const longParsed = parseArgs(['--version'], 'subminer', {});
const statsParsed = parseArgs(['stats', 'cleanup', '-v'], 'subminer', {});
assert.equal(shortParsed.version, true);
assert.equal(longParsed.version, true);
assert.equal(statsParsed.version, false);
assert.equal(statsParsed.statsCleanupVocab, true);
});
test('parseArgs maps jellyfin play action and log-level override', () => {
const parsed = parseArgs(['jellyfin', 'play', '--log-level', 'debug'], 'subminer', {});
@@ -90,6 +107,33 @@ test('parseArgs maps config show action', () => {
assert.equal(parsed.configPath, false);
});
test('parseArgs maps bare config command to settings window', () => {
const parsed = parseArgs(['config'], 'subminer', {});
assert.equal(parsed.configSettings, true);
assert.equal(parsed.configPath, false);
assert.equal(parsed.configShow, false);
});
test('parseArgs maps config path action to config path output', () => {
const parsed = parseArgs(['config', 'path'], 'subminer', {});
assert.equal(parsed.configPath, true);
assert.equal(parsed.configSettings, false);
});
test('parseArgs rejects removed config open and launch actions', () => {
const openExit = withProcessExitIntercept(() => {
parseArgs(['config', 'open'], 'subminer', {});
});
const exit = withProcessExitIntercept(() => {
parseArgs(['config', 'launch'], 'subminer', {});
});
assert.equal(openExit.code, 1);
assert.equal(exit.code, 1);
});
test('parseArgs maps mpv idle action', () => {
const parsed = parseArgs(['mpv', 'idle'], 'subminer', {});
+2
View File
@@ -134,7 +134,9 @@ export interface Args {
dictionaryTarget?: string;
doctor: boolean;
doctorRefreshKnownWords: boolean;
version: boolean;
update?: boolean;
configSettings: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
+4748
View File
File diff suppressed because it is too large Load Diff
+4133
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.15.0-beta.2",
"version": "0.15.0-beta.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -18,8 +18,9 @@
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
"build:stats": "cd stats && bun run build",
"dev:stats": "cd stats && bun run dev",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:launcher && bun run build:assets",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
"changelog:build": "bun run scripts/build-changelog.ts build-release",
"changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:docs": "bun run scripts/build-changelog.ts docs",
@@ -39,6 +40,7 @@
"lint": "bun run lint:stats",
"docs:dev": "bun run --cwd docs-site docs:dev",
"docs:build": "bun run --cwd docs-site docs:build",
"docs:build:versioned": "bun run scripts/build-versioned-docs.ts",
"docs:preview": "bun run --cwd docs-site docs:preview",
"docs:test": "bun run --cwd docs-site test",
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
@@ -48,7 +50,7 @@
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.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/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts",
"test:core:src": "bun test src/preload-settings.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
@@ -70,7 +72,7 @@
"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 run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/docs-versioning.test.ts scripts/docs-versioned-assets.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start",
@@ -116,6 +118,7 @@
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"vscode-json-languageservice": "^5.7.2",
"ws": "^8.19.0"
},
"devDependencies": {
+17 -9
View File
@@ -3,25 +3,33 @@
## Highlights
### Added
**Auto-Update:** The tray and `subminer -u` now check for SubMiner releases and prompt you to install updates, covering the app itself, the command-line launcher, and Linux rofi themes. Update notifications are configurable, downloads are checksum-verified, and an opt-in prerelease channel lets you receive beta and RC builds.
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`. Checks include checksum verification, configurable notifications, and an opt-in channel for prerelease builds. The `subminer` launcher and Linux rofi theme are also updated automatically.
**First-Run Setup:** The app can now install Bun and the `subminer` command-line launcher during first run on Linux, macOS, and Windows. On Windows, a `subminer.cmd` PATH shim lets you type `subminer` in any terminal without manually adding `SubMiner.exe` to PATH.
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH.
### Fixed
**macOS Overlay:** Controls on the mpv window are now clickable before you hover the subtitle bars. Transient mpv window focus misses no longer hide the overlay; minimizing mpv still hides it as expected.
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay now hides when mpv loses focus or is minimized, stays stable through transient window-tracking misses, remains correctly layered during stats mouse passthrough, and opens over fullscreen mpv without switching Spaces. Passthrough is also fixed so mpv controls stay clickable before hovering a subtitle bar. Background tracking overhead is reduced while mpv is stably focused.
**Subtitle Sync:** The subtitle sync modal on macOS now opens reliably: no more flash-and-hide on the first attempt or stale modal state left after syncing.
- **Subtitle Sync Modal:** Fixed a macOS issue where opening the subtitle sync modal would flash and disappear on the first attempt, or leave stale state after syncing.
**Updater:** Update checks on Linux now use GitHub release metadata instead of the native Electron updater, preventing tray app crashes. macOS builds that cannot auto-install updates show a manual install prompt instead of a non-functional restart prompt. macOS update dialogs are brought to the front when `subminer --update` is run from the launcher.
- **Controller:** Controller config and debug shortcuts now stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge, remaps are saved per controller profile, and individual bindings can be reset to their defaults.
**Linux Command-Line Updater:** `subminer -u` now performs release updates directly from the launcher without requiring the tray app, and reports "up to date" without downloading assets when already on the latest version.
- **AniList Progress:** Progress threshold checks now use fresh playback position data, so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status.
**Launcher Setup:** Linux first-run launcher installs now build with a valid Bun shebang. `subminer app --setup` correctly opens the setup flow even when SubMiner is already running in the background. On macOS, first-run setup recognizes existing launchers in Homebrew or user PATH directories, and manual installs avoid Homebrew-managed locations. The setup window now quits automatically after first-run setup completes, returning control to the terminal.
- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
**Tray App:** Fixed several issues with tray-launched Yomitan settings: closing the window no longer quits the tray app, a close-only menu replaces the default native menu, and settings loading no longer blocks other tray actions. An in-page close button is now available on Hyprland, where native window controls may be absent. Disabled the embedded popup preview in the settings window to prevent renderer hangs. Fixed a startup race condition that could leave extension loading in an error state, and corrected focus handling for the session help modal when mpv is not running.
- **Updater - Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists.
**Build:** One-shot `make clean build install` flows now correctly pick up the AppImage built in the same invocation.
- **Updater - macOS:** Update dialogs now come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native updater path without triggering premature Squirrel install checks.
- **Setup - macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, returning control to the terminal.
- **Launcher - Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running.
- **Build - Linux Install:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
## Installation
+66
View File
@@ -509,6 +509,72 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
}
});
test('writePrereleaseNotesForVersion reuses existing prerelease notes when adding new fragments', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-reuse-existing-notes');
const projectRoot = path.join(workspace, 'SubMiner');
const existingNotes = [
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
'',
'## Highlights',
'### Added',
'- Overlay: Previous beta entry.',
'',
'## Installation',
'',
'See the README and docs/installation guide for full setup steps.',
'',
'## Assets',
'',
'- Linux: `SubMiner.AppImage`',
'',
].join('\n');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.2' }, null, 2),
'utf8',
);
fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: fixed', 'area: launcher', '', '- Fixed launcher prerelease packaging.'].join('\n'),
'utf8',
);
try {
const stub = recordingRunClaude((input) => {
if (!input.includes('Overlay: Previous beta entry.')) {
return '### Fixed\n- Launcher: Added only the latest fix.';
}
return [
'### Added',
'- Overlay: Previous beta entry.',
'',
'### Fixed',
'- Launcher: Added only the latest fix.',
].join('\n');
});
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.2',
deps: { runClaude: stub.runClaude },
});
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
assert.match(stub.calls[0]!.input, /EXISTING PRERELEASE NOTES/);
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /- Overlay: Previous beta entry\./);
assert.match(prereleaseNotes, /- Launcher: Added only the latest fix\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-rc-notes');
+26 -3
View File
@@ -290,6 +290,7 @@ function serializeFragmentsForPrompt(
mode: PolishMode,
version: string,
date?: string,
existingReleaseNotes?: string,
): string {
const header: string[] = [`MODE: ${mode}`, `VERSION: ${version}`];
if (date) {
@@ -307,7 +308,11 @@ function serializeFragmentsForPrompt(
].join('\n');
});
return [...header, '', ...fragmentBlocks].join('\n\n');
const existingNotesBlock = existingReleaseNotes?.trim()
? ['EXISTING PRERELEASE NOTES', existingReleaseNotes.trim()]
: [];
return [...header, '', ...existingNotesBlock, '', ...fragmentBlocks].join('\n\n');
}
function validatePolishedOutput(
@@ -340,10 +345,11 @@ function polishFragmentsWithClaude(
mode: PolishMode;
version: string;
date?: string;
existingReleaseNotes?: string;
deps?: ChangelogFsDeps;
},
): string {
const { mode, version, date } = options;
const { mode, version, date, existingReleaseNotes } = options;
const runClaude = options.deps?.runClaude ?? defaultRunClaude;
const filtered =
@@ -361,8 +367,18 @@ function polishFragmentsWithClaude(
);
}
const reuseInstructions = existingReleaseNotes?.trim()
? [
'## Existing Prerelease Notes',
'',
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Reuse those highlight bullets as the baseline, preserve their meaning and wording where possible, then merge in only new or changed fragment material. Deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, Installation, or Assets sections.',
'',
].join('\n')
: '';
const prompt =
POLISH_PROMPT_INSTRUCTIONS + serializeFragmentsForPrompt(filtered, mode, version, date);
POLISH_PROMPT_INSTRUCTIONS +
reuseInstructions +
serializeFragmentsForPrompt(filtered, mode, version, date, existingReleaseNotes);
const output = runClaude(prompt, CLAUDE_CLI_ARGS);
return validatePolishedOutput(output, mode, hasInternalFragments);
}
@@ -780,6 +796,8 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
verifyRequestedVersionMatchesPackageVersion(options ?? {});
const cwd = options?.cwd ?? process.cwd();
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const version = resolveVersion(options ?? {});
if (!isSupportedPrereleaseVersion(version)) {
throw new Error(
@@ -792,9 +810,14 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
throw new Error('No changelog fragments found in changes/.');
}
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
const existingReleaseNotes = existsSync(prereleaseNotesPath)
? readFileSync(prereleaseNotesPath, 'utf8')
: undefined;
const changes = polishFragmentsWithClaude(fragments, {
mode: 'release-notes',
version,
existingReleaseNotes,
deps: options?.deps,
});
return writeReleaseNotesFile(cwd, changes, options?.deps, {
+392
View File
@@ -0,0 +1,392 @@
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
readlinkSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { join, resolve } from 'node:path';
import {
collectSharedAssetPaths,
dedupeVersionedPublicAssets,
pruneArchiveCacheGenerations,
} from './docs-versioned-assets';
import {
buildVersionManifest,
stableTagsWithDocs,
versionArchiveCacheKey,
versionArchiveCacheName,
versionOutputPath,
versionPath,
} from './docs-versioning';
const repoRoot = resolve(__dirname, '..');
const currentDocsSite = join(repoRoot, 'docs-site');
const buildRoot = join(repoRoot, '.tmp/docs-versioned-build');
const aggregateOutDir = join(repoRoot, '.tmp/docs-versioned-site');
const archiveCacheRoot = join(repoRoot, '.tmp/docs-versioned-archive-cache');
const maxCloudflareFiles = 20_000;
const maxCloudflareFileBytes = 25 * 1024 * 1024;
function run(
command: string,
args: string[],
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? repoRoot,
env: options.env ?? process.env,
stdio: 'inherit',
});
if (result.status !== 0) {
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
}
}
function capture(command: string, args: string[]): string {
const result = spawnSync(command, args, {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(result.stderr || `Command failed: ${command} ${args.join(' ')}`);
}
return result.stdout;
}
function archiveDocsSite(ref: string, targetDir: string) {
mkdirSync(targetDir, { recursive: true });
const archive = spawnSync('git', ['archive', '--format=tar', ref, 'docs-site'], {
cwd: repoRoot,
encoding: 'buffer',
maxBuffer: 1024 * 1024 * 1024,
});
if (archive.status !== 0 || !archive.stdout) {
throw new Error(`Unable to archive docs-site from ${ref}`);
}
const extract = spawnSync('tar', ['-x', '-C', targetDir], {
input: archive.stdout,
stdio: ['pipe', 'inherit', 'inherit'],
});
if (extract.status !== 0) {
throw new Error(`Unable to extract docs-site archive from ${ref}`);
}
}
function copyCurrentDocsSite(targetDir: string) {
mkdirSync(targetDir, { recursive: true });
cpSync(currentDocsSite, join(targetDir, 'docs-site'), {
recursive: true,
dereference: false,
filter: (source) =>
!/[\\/]node_modules([\\/]|$)/.test(source) &&
!/[\\/]\\.vitepress[\\/]dist([\\/]|$)/.test(source),
});
}
function overlayCurrentVitePress(snapshotDocsSite: string) {
const targetVitePress = join(snapshotDocsSite, '.vitepress');
rmSync(targetVitePress, { recursive: true, force: true });
cpSync(join(currentDocsSite, '.vitepress'), targetVitePress, {
recursive: true,
filter: (source) => !isGeneratedVitePressPath(source),
});
const currentThemeFonts = join(currentDocsSite, 'public/assets/fonts');
if (existsSync(currentThemeFonts)) {
cpSync(currentThemeFonts, join(snapshotDocsSite, 'public/assets/fonts'), {
recursive: true,
force: true,
});
}
}
function linkDocsDependencies(snapshotDocsSite: string) {
const currentNodeModules = join(currentDocsSite, 'node_modules');
const targetNodeModules = join(snapshotDocsSite, 'node_modules');
if (!existsSync(currentNodeModules) || existsSync(targetNodeModules)) {
return;
}
symlinkSync(currentNodeModules, targetNodeModules, 'dir');
}
function prepareSnapshot(name: string, ref?: string): string {
const snapshotRoot = join(buildRoot, name);
rmSync(snapshotRoot, { recursive: true, force: true });
if (ref) {
archiveDocsSite(ref, snapshotRoot);
} else {
copyCurrentDocsSite(snapshotRoot);
}
const snapshotDocsSite = join(snapshotRoot, 'docs-site');
overlayCurrentVitePress(snapshotDocsSite);
linkDocsDependencies(snapshotDocsSite);
return snapshotDocsSite;
}
function tagHasDocsSite(tag: string): boolean {
const result = spawnSync('git', ['cat-file', '-e', `${tag}:docs-site/package.json`], {
cwd: repoRoot,
});
return result.status === 0;
}
function getStableVersions(): string[] {
const tags = capture('git', ['tag', '--list', 'v*'])
.split('\n')
.map((tag) => tag.trim())
.filter(Boolean);
return stableTagsWithDocs(tags, tagHasDocsSite);
}
function buildDocs(options: {
snapshotDocsSite: string;
base: string;
outDir: string;
channel: string;
version?: string;
latestStable: string;
manifestJson: string;
}) {
console.info(`[docs] building ${options.version ?? options.channel} -> ${options.base}`);
run('bun', ['run', '--cwd', currentDocsSite, 'vitepress', 'build', options.snapshotDocsSite], {
cwd: repoRoot,
env: {
...process.env,
SUBMINER_DOCS_BASE: options.base,
SUBMINER_DOCS_OUT_DIR: options.outDir,
SUBMINER_DOCS_SOURCE_DIR: options.snapshotDocsSite,
SUBMINER_DOCS_CHANNEL: options.channel,
SUBMINER_DOCS_VERSION: options.version ?? '',
SUBMINER_DOCS_LATEST_STABLE: options.latestStable,
SUBMINER_DOCS_VERSION_MANIFEST: options.manifestJson,
VITE_EXTRA_EXTENSIONS: 'jsonc',
},
});
}
function updateHashWithPath(hash: ReturnType<typeof createHash>, path: string) {
if (isSharedInternalsHashIgnoredPath(path)) {
return;
}
const stat = lstatSync(path);
const relativePath = path.replace(repoRoot, '');
if (stat.isSymbolicLink()) {
hash.update(`symlink:${relativePath}`);
hash.update(readlinkSync(path));
return;
}
if (stat.isDirectory()) {
hash.update(`dir:${relativePath}`);
for (const entry of readdirSync(path).sort()) {
updateHashWithPath(hash, join(path, entry));
}
return;
}
hash.update(`file:${relativePath}`);
hash.update(readFileSync(path));
}
function isGeneratedVitePressPath(path: string): boolean {
return /[\\/]\\.vitepress[\\/](cache|dist)([\\/]|$)/.test(path);
}
function isSharedInternalsHashIgnoredPath(path: string): boolean {
return isGeneratedVitePressPath(path) || /\.test\.[cm]?[jt]s$/.test(path);
}
function computeSharedInternalsHash(): string {
const hash = createHash('sha256');
hash.update(
`version-link-origin:${process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN === 'local' ? 'local' : 'production'}`,
);
const paths = [
join(currentDocsSite, '.vitepress'),
join(currentDocsSite, 'public/assets/fonts'),
join(currentDocsSite, 'package.json'),
join(currentDocsSite, 'bun.lock'),
join(repoRoot, 'scripts/build-versioned-docs.ts'),
join(repoRoot, 'scripts/docs-versioning.ts'),
];
for (const path of paths) {
if (existsSync(path)) {
updateHashWithPath(hash, path);
}
}
return hash.digest('hex');
}
function archiveCachePath(version: string, sharedInternalsHash: string): string {
return join(archiveCacheRoot, versionArchiveCacheName(version, sharedInternalsHash));
}
function restoreCachedArchive(version: string, sharedInternalsHash: string): boolean {
const cachedArchive = archiveCachePath(version, sharedInternalsHash);
if (!existsSync(cachedArchive)) {
return false;
}
console.info(`[docs] cache hit ${version}`);
cpSync(cachedArchive, join(aggregateOutDir, versionOutputPath(version)), {
recursive: true,
force: true,
});
return true;
}
function saveArchiveCache(version: string, sharedInternalsHash: string) {
const outputPath = join(aggregateOutDir, versionOutputPath(version));
if (!existsSync(outputPath)) {
return;
}
const cachedArchive = archiveCachePath(version, sharedInternalsHash);
rmSync(cachedArchive, { recursive: true, force: true });
mkdirSync(archiveCacheRoot, { recursive: true });
cpSync(outputPath, cachedArchive, { recursive: true, force: true });
}
function assertCloudflarePagesLimits(root: string) {
let fileCount = 0;
const oversizedFiles: string[] = [];
function walk(dir: string) {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
continue;
}
if (stat.isDirectory()) {
walk(path);
continue;
}
fileCount += 1;
if (stat.size > maxCloudflareFileBytes) {
oversizedFiles.push(path);
}
}
}
walk(root);
if (fileCount > maxCloudflareFiles) {
throw new Error(
`Versioned docs output has ${fileCount} files; Cloudflare Pages free plan limit is ${maxCloudflareFiles}.`,
);
}
if (oversizedFiles.length > 0) {
throw new Error(`Versioned docs output has files over 25 MiB:\n${oversizedFiles.join('\n')}`);
}
}
function main() {
const stableVersions = getStableVersions();
const latestStable = stableVersions[0];
if (!latestStable) {
throw new Error('No stable release tags with docs-site/package.json found.');
}
const manifest = buildVersionManifest({ latestStable, stableVersions });
const manifestJson = JSON.stringify(manifest);
const sharedInternalsHash = computeSharedInternalsHash();
const archiveCacheKey = versionArchiveCacheKey({ sharedInternalsHash, manifestJson });
const sharedAssetPaths = collectSharedAssetPaths(join(currentDocsSite, 'public/assets'));
console.info(`[docs] archive cache key ${archiveCacheKey.slice(0, 12)}`);
rmSync(buildRoot, { recursive: true, force: true });
rmSync(aggregateOutDir, { recursive: true, force: true });
mkdirSync(buildRoot, { recursive: true });
mkdirSync(aggregateOutDir, { recursive: true });
const latestStableSnapshot = prepareSnapshot(latestStable, latestStable);
buildDocs({
snapshotDocsSite: latestStableSnapshot,
base: '/',
outDir: aggregateOutDir,
channel: 'stable-root',
version: latestStable,
latestStable,
manifestJson,
});
for (const version of stableVersions) {
if (restoreCachedArchive(version, archiveCacheKey)) {
continue;
}
console.info(`[docs] rebuilding archive ${version}`);
const snapshot =
version === latestStable ? latestStableSnapshot : prepareSnapshot(version, version);
buildDocs({
snapshotDocsSite: snapshot,
base: versionPath(version),
outDir: join(aggregateOutDir, versionOutputPath(version)),
channel: 'stable-archive',
version,
latestStable,
manifestJson,
});
dedupeVersionedPublicAssets({
outDir: join(aggregateOutDir, versionOutputPath(version)),
base: versionPath(version),
sharedAssetPaths,
});
saveArchiveCache(version, archiveCacheKey);
}
const mainSnapshot = prepareSnapshot('main');
buildDocs({
snapshotDocsSite: mainSnapshot,
base: '/main/',
outDir: join(aggregateOutDir, 'main'),
channel: 'main',
version: 'main',
latestStable,
manifestJson,
});
dedupeVersionedPublicAssets({
outDir: join(aggregateOutDir, 'main'),
base: '/main/',
sharedAssetPaths,
});
writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`);
assertCloudflarePagesLimits(aggregateOutDir);
const prunedArchives = pruneArchiveCacheGenerations({
cacheRoot: archiveCacheRoot,
activeCacheKey: archiveCacheKey,
});
if (prunedArchives.length > 0) {
console.info(`[docs] pruned ${prunedArchives.length} stale archive cache directories`);
}
rmSync(buildRoot, { recursive: true, force: true });
}
main();
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
dedupeVersionedPublicAssets,
pruneArchiveCacheGenerations,
rewriteSharedAssetReferences,
} from './docs-versioned-assets';
function tempDir() {
return mkdtempSync(join(tmpdir(), 'subminer-docs-versioned-assets-'));
}
describe('docs versioned asset dedupe', () => {
test('rewrites version-scoped public asset references to shared root assets', () => {
const html =
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/v/0.14.0/assets/minecard.webm">';
const expected =
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/assets/minecard.webm">';
expect(rewriteSharedAssetReferences(html, '/v/0.14.0/', new Set(['minecard.webm']))).toBe(
expected,
);
expect(rewriteSharedAssetReferences(html, '/v/0.14.0', new Set(['minecard.webm']))).toBe(
expected,
);
});
test('does not rewrite longer asset paths with a shared asset prefix', () => {
expect(
rewriteSharedAssetReferences(
[
'<script src="/v/0.14.0/assets/foo.js"></script>',
'<script src="/v/0.14.0/assets/foo.js?v=1"></script>',
'<script src="/v/0.14.0/assets/foo.js.map"></script>',
].join(''),
'/v/0.14.0/',
new Set(['foo.js']),
),
).toBe(
[
'<script src="/assets/foo.js"></script>',
'<script src="/assets/foo.js?v=1"></script>',
'<script src="/v/0.14.0/assets/foo.js.map"></script>',
].join(''),
);
});
test('removes duplicated version public assets while preserving generated VitePress assets', async () => {
const dir = tempDir();
try {
mkdirSync(join(dir, 'assets/chunks'), { recursive: true });
writeFileSync(join(dir, 'assets/style.hash.css'), 'body{}');
writeFileSync(join(dir, 'assets/chunks/theme.hash.js'), 'export {};');
writeFileSync(join(dir, 'assets/minecard.webm'), 'large video');
writeFileSync(
join(dir, 'index.html'),
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/v/0.14.0/assets/minecard.webm">',
);
const result = dedupeVersionedPublicAssets({
outDir: dir,
base: '/v/0.14.0',
sharedAssetPaths: new Set(['minecard.webm']),
});
expect(result.rewrittenFiles).toEqual([join(dir, 'index.html')]);
expect(existsSync(join(dir, 'assets/style.hash.css'))).toBe(true);
expect(existsSync(join(dir, 'assets/chunks/theme.hash.js'))).toBe(true);
expect(existsSync(join(dir, 'assets/minecard.webm'))).toBe(false);
expect(readFileSync(join(dir, 'index.html'), 'utf8')).toBe(
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/assets/minecard.webm">',
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test('keeps root public assets because they are the shared copy', async () => {
const dir = tempDir();
try {
mkdirSync(join(dir, 'assets'), { recursive: true });
writeFileSync(join(dir, 'assets/minecard.webm'), 'large video');
const result = dedupeVersionedPublicAssets({
outDir: dir,
base: '/',
sharedAssetPaths: new Set(['minecard.webm']),
});
expect(result.removedAssetsDir).toBe(false);
expect(existsSync(join(dir, 'assets'))).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
describe('docs archive cache pruning', () => {
test('removes stale cache generations while keeping the active generation', async () => {
const dir = tempDir();
try {
mkdirSync(join(dir, 'active123456-v0.14.0'), { recursive: true });
mkdirSync(join(dir, 'stale654321-v0.14.0'), { recursive: true });
mkdirSync(join(dir, 'stale654321-v0.13.0'), { recursive: true });
const removed = pruneArchiveCacheGenerations({
cacheRoot: dir,
activeCacheKey: 'active123456abcdef',
});
expect(removed.sort()).toEqual([
join(dir, 'stale654321-v0.13.0'),
join(dir, 'stale654321-v0.14.0'),
]);
expect(existsSync(join(dir, 'active123456-v0.14.0'))).toBe(true);
expect(existsSync(join(dir, 'stale654321-v0.14.0'))).toBe(false);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
+148
View File
@@ -0,0 +1,148 @@
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
const textOutputPattern = /\.(?:css|html|js|json|map|mjs|txt|xml)$/;
function normalizeBase(base: string): string {
return base === '/' ? '/' : `${base.replace(/\/+$/, '')}/`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function collectSharedAssetPaths(publicAssetsDir: string): Set<string> {
if (!existsSync(publicAssetsDir)) {
return new Set();
}
return new Set(
walkFiles(publicAssetsDir).map((file) => relative(publicAssetsDir, file).split('\\').join('/')),
);
}
export function rewriteSharedAssetReferences(
content: string,
base: string,
sharedAssetPaths: Set<string>,
): string {
const normalizedBase = normalizeBase(base);
if (normalizedBase === '/') {
return content;
}
let rewritten = content;
const escapedBase = escapeRegExp(normalizedBase);
for (const assetPath of sharedAssetPaths) {
const escapedAssetPath = escapeRegExp(assetPath);
rewritten = rewritten.replace(
new RegExp(`${escapedBase}assets/${escapedAssetPath}(?=$|[?#"'()\\s<])`, 'g'),
`/assets/${assetPath}`,
);
}
return rewritten;
}
function walkFiles(root: string): string[] {
const files: string[] = [];
function walk(dir: string) {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
continue;
}
if (stat.isDirectory()) {
walk(path);
continue;
}
files.push(path);
}
}
walk(root);
return files;
}
export function dedupeVersionedPublicAssets(options: {
outDir: string;
base: string;
sharedAssetPaths: Set<string>;
}): {
removedAssetsDir: boolean;
rewrittenFiles: string[];
} {
const normalizedBase = normalizeBase(options.base);
const rewrittenFiles: string[] = [];
for (const file of walkFiles(options.outDir)) {
if (!textOutputPattern.test(file)) {
continue;
}
const before = readFileSync(file, 'utf8');
const after = rewriteSharedAssetReferences(before, normalizedBase, options.sharedAssetPaths);
if (after === before) {
continue;
}
writeFileSync(file, after);
rewrittenFiles.push(file);
}
const assetsDir = join(options.outDir, 'assets');
if (normalizedBase !== '/') {
for (const assetPath of options.sharedAssetPaths) {
rmSync(join(assetsDir, assetPath), { force: true });
}
removeEmptyDirectories(assetsDir);
}
const removedAssetsDir = !existsSync(assetsDir);
return { removedAssetsDir, rewrittenFiles };
}
function removeEmptyDirectories(root: string) {
if (!existsSync(root) || !lstatSync(root).isDirectory()) {
return;
}
for (const entry of readdirSync(root)) {
const path = join(root, entry);
if (lstatSync(path).isDirectory()) {
removeEmptyDirectories(path);
}
}
if (readdirSync(root).length === 0) {
rmSync(root, { recursive: true, force: true });
}
}
export function pruneArchiveCacheGenerations(options: {
cacheRoot: string;
activeCacheKey: string;
}): string[] {
if (!existsSync(options.cacheRoot)) {
return [];
}
const activePrefix = options.activeCacheKey.slice(0, 12);
const removed: string[] = [];
for (const entry of readdirSync(options.cacheRoot)) {
const path = join(options.cacheRoot, entry);
if (!lstatSync(path).isDirectory()) {
continue;
}
if (entry.startsWith(`${activePrefix}-`)) {
continue;
}
rmSync(path, { recursive: true, force: true });
removed.push(path);
}
return removed;
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test';
import {
buildVersionManifest,
compareStableVersionsDesc,
versionArchiveCacheKey,
isStableReleaseTag,
stableTagsWithDocs,
versionArchiveCacheName,
versionOutputPath,
versionPath,
} from './docs-versioning';
describe('docs versioning helpers', () => {
test('stable tag filtering excludes beta and rc tags', () => {
expect(isStableReleaseTag('v0.14.0')).toBe(true);
expect(isStableReleaseTag('v0.15.0-beta.3')).toBe(false);
expect(isStableReleaseTag('v0.15.0-rc.1')).toBe(false);
});
test('latest stable resolves to v0.14.0 when beta tags are present', () => {
const tags = ['v0.13.0', 'v0.15.0-beta.3', 'v0.14.0'].sort(compareStableVersionsDesc);
expect(tags[0]).toBe('v0.14.0');
});
test('tags before docs-site are skipped', () => {
const tags = ['v0.12.0', 'v0.13.0', 'v0.14.0'];
const hasDocsSite = (tag: string) => tag !== 'v0.12.0';
expect(stableTagsWithDocs(tags, hasDocsSite)).toEqual(['v0.14.0', 'v0.13.0']);
});
test('version manifest paths are normalized', () => {
expect(versionPath('v0.14.0')).toBe('/v/0.14.0/');
expect(
buildVersionManifest({
latestStable: 'v0.14.0',
stableVersions: ['v0.14.0'],
}),
).toEqual({
latestStable: 'v0.14.0',
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: [{ version: 'v0.14.0', path: '/v/0.14.0/' }],
});
});
test('archive cache names are normalized by version and shared internals hash', () => {
expect(versionArchiveCacheName('v0.14.0', 'abcdef1234567890')).toBe('abcdef123456-v0.14.0');
});
test('archive cache keys change when manifest contents change', () => {
const firstKey = versionArchiveCacheKey({
sharedInternalsHash: 'abcdef1234567890',
manifestJson: '{"latestStable":"v0.14.0"}',
});
const secondKey = versionArchiveCacheKey({
sharedInternalsHash: 'abcdef1234567890',
manifestJson: '{"latestStable":"v0.15.0"}',
});
expect(firstKey).not.toBe(secondKey);
});
test('archive output paths stay relative for filesystem joins', () => {
expect(versionOutputPath('v0.14.0')).toBe('v/0.14.0');
});
});
+96
View File
@@ -0,0 +1,96 @@
import { createHash } from 'node:crypto';
export type DocsVersionEntry = {
version: string;
path: string;
};
export type DocsChannelEntry = {
label: string;
path: string;
};
export type DocsVersionManifest = {
latestStable: string;
channels: DocsChannelEntry[];
versions: DocsVersionEntry[];
};
const STABLE_TAG_PATTERN = /^v\d+\.\d+\.\d+$/;
export function isStableReleaseTag(tag: string): boolean {
return STABLE_TAG_PATTERN.test(tag);
}
function parseStableVersion(tag: string): [number, number, number] {
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
if (!match) {
throw new Error(`Invalid stable SubMiner version tag: ${tag}`);
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function compareStableVersionsDesc(a: string, b: string): number {
if (!isStableReleaseTag(a) && !isStableReleaseTag(b)) return a.localeCompare(b);
if (!isStableReleaseTag(a)) return 1;
if (!isStableReleaseTag(b)) return -1;
const parsedA = parseStableVersion(a);
const parsedB = parseStableVersion(b);
for (let index = 0; index < parsedA.length; index += 1) {
const difference = parsedB[index]! - parsedA[index]!;
if (difference !== 0) return difference;
}
return 0;
}
export function versionPath(version: string): string {
return `/v/${version.replace(/^v/, '')}/`;
}
export function versionOutputPath(version: string): string {
return `v/${version.replace(/^v/, '')}`;
}
export function versionArchiveCacheName(version: string, sharedInternalsHash: string): string {
return `${sharedInternalsHash.slice(0, 12)}-${version}`;
}
export function versionArchiveCacheKey(options: {
sharedInternalsHash: string;
manifestJson: string;
}): string {
const hash = createHash('sha256');
hash.update('shared-internals:');
hash.update(options.sharedInternalsHash);
hash.update('\nmanifest:');
hash.update(options.manifestJson);
return hash.digest('hex');
}
export function stableTagsWithDocs(
tags: string[],
hasDocsSite: (tag: string) => boolean,
): string[] {
return tags.filter(isStableReleaseTag).filter(hasDocsSite).sort(compareStableVersionsDesc);
}
export function buildVersionManifest(options: {
latestStable: string;
stableVersions: string[];
}): DocsVersionManifest {
return {
latestStable: options.latestStable,
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: options.stableVersions.map((version) => ({
version,
path: versionPath(version),
})),
};
}
+58 -7
View File
@@ -7,7 +7,7 @@
// It works with both bundled and unbundled mpv installations.
//
// Usage: swift get-mpv-window-macos.swift
// Output: "x,y,width,height" or "not-found"
// Output: "x,y,width,height,focused", "minimized", "active", "inactive", or "not-found"
//
import Cocoa
@@ -25,9 +25,16 @@ private struct WindowState {
let focused: Bool
}
private struct FrontmostApplicationState {
let pid: pid_t
let isMpv: Bool
}
private enum WindowLookupResult {
case visible(WindowState)
case minimized
case active
case inactive
}
private let targetMpvSocketPath: String? = {
@@ -146,8 +153,41 @@ private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? {
return geometry
}
private func frontmostApplicationPid() -> pid_t? {
NSWorkspace.shared.frontmostApplication?.processIdentifier
private func frontmostApplicationState() -> FrontmostApplicationState? {
guard let app = NSWorkspace.shared.frontmostApplication else {
return nil
}
return FrontmostApplicationState(
pid: app.processIdentifier,
isMpv: app.localizedName.map(normalizedMpvName) ?? false
)
}
private func isFocusedMpvWindow(ownerPid: pid_t, frontmost: FrontmostApplicationState?) -> Bool {
guard let frontmost = frontmost else {
return false
}
if frontmost.pid == ownerPid {
return true
}
return frontmost.isMpv && windowHasTargetSocket(ownerPid)
}
private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool {
guard let frontmost = frontmost, frontmost.isMpv else {
return false
}
if windowHasTargetSocket(frontmost.pid) {
return true
}
// When macOS says mpv is frontmost but geometry APIs miss, keep the
// overlay stable even if ps cannot expose the socket argument.
return targetMpvSocketPath != nil
}
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
@@ -158,7 +198,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
return normalizedMpvName(name)
}
let frontmostPid = frontmostApplicationPid()
let frontmost = frontmostApplicationState()
var foundMinimizedTargetWindow = false
for app in runningApps {
@@ -198,7 +238,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
return .visible(
WindowState(
geometry: geometry,
focused: frontmostPid == windowPid
focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)
)
)
}
@@ -217,7 +257,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
// Use on-screen layer-0 windows to avoid off-screen helpers/shadows.
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
let frontmostPid = frontmostApplicationPid()
let frontmost = frontmostApplicationState()
for window in windowList {
guard let ownerName = window[kCGWindowOwnerName as String] as? String,
@@ -260,7 +300,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
return WindowState(
geometry: geometry,
focused: frontmostPid == ownerPid
focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)
)
}
@@ -274,6 +314,13 @@ private let lookupResult: WindowLookupResult? = {
if let cgWindow = windowStateFromCoreGraphics() {
return .visible(cgWindow)
}
let frontmost = frontmostApplicationState()
if isFrontmostTargetMpv(frontmost) {
return .active
}
if frontmost != nil {
return .inactive
}
return nil
}()
@@ -285,6 +332,10 @@ if let result = lookupResult {
)
case .minimized:
print("minimized")
case .active:
print("active")
case .inactive:
print("inactive")
}
} else {
print("not-found")
+61
View File
@@ -31,3 +31,64 @@ test('minimized Accessibility windows are validated by PID and socket before rep
'target socket must be validated before accepting a minimized window',
);
});
test('focused mpv window follows the frontmost mpv app signal', () => {
const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow');
assert.notEqual(focusHelperIndex, -1);
const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1);
const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex);
assert.ok(
focusHelperBody.includes('frontmost.pid == ownerPid'),
'matching frontmost PID should mark the mpv window focused',
);
assert.ok(
focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'),
'frontmost mpv app should mark the target mpv window focused even when PIDs differ',
);
assert.ok(
source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'),
'Accessibility path should use the shared focused mpv helper',
);
assert.ok(
source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'),
'CoreGraphics path should use the shared focused mpv helper',
);
});
test('frontmost mpv app emits active state when geometry lookup misses', () => {
assert.ok(
/case\s+\.active:/.test(source),
'helper should expose an active state without window geometry',
);
assert.ok(
source.includes('if windowHasTargetSocket(frontmost.pid)'),
'active state should still accept a matching target socket when available',
);
assert.ok(
source.includes('return targetMpvSocketPath != nil'),
'active state should preserve frontmost mpv even if command-line socket detection fails',
);
assert.ok(
source.includes('return .active'),
'lookup should preserve active mpv state after geometry lookup misses',
);
assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker');
});
test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => {
assert.ok(
/case\s+\.inactive:/.test(source),
'helper should expose an inactive state without window geometry',
);
assert.ok(
source.includes('if frontmost != nil'),
'helper should distinguish a known non-mpv frontmost app from an unknown miss',
);
assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive');
assert.ok(
source.includes('print("inactive")'),
'inactive state should be printed for the tracker',
);
});
+16 -5
View File
@@ -7,6 +7,8 @@ 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 settingsSourceDir = path.join(repoRoot, 'src', 'settings');
const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
@@ -21,14 +23,22 @@ function copyFile(sourcePath, 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'), {
function copyAssets(sourceDir, outputDir, label) {
copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html'));
copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css'));
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), {
recursive: true,
force: true,
});
process.stdout.write(`Staged renderer assets in ${rendererOutputDir}\n`);
process.stdout.write(`Staged ${label} assets in ${outputDir}\n`);
}
function copyRendererAssets() {
copyAssets(rendererSourceDir, rendererOutputDir, 'renderer');
}
function copySettingsAssets() {
copyAssets(settingsSourceDir, settingsOutputDir, 'settings');
}
function fallbackToMacosSource() {
@@ -70,6 +80,7 @@ function buildMacosHelper() {
function main() {
copyRendererAssets();
copySettingsAssets();
buildMacosHelper();
}
+41
View File
@@ -0,0 +1,41 @@
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
import { buildVersionManifest, stableTagsWithDocs } from './docs-versioning';
const repoRoot = resolve(__dirname, '..');
function capture(command: string, args: string[]): string {
const result = spawnSync(command, args, {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(result.stderr || `Command failed: ${command} ${args.join(' ')}`);
}
return result.stdout;
}
function tagHasDocsSite(tag: string): boolean {
const result = spawnSync('git', ['cat-file', '-e', `${tag}:docs-site/package.json`], {
cwd: repoRoot,
});
return result.status === 0;
}
const stableVersions = stableTagsWithDocs(
capture('git', ['tag', '--list', 'v*'])
.split('\n')
.map((tag) => tag.trim())
.filter(Boolean),
tagHasDocsSite,
);
const latestStable = stableVersions[0];
if (!latestStable) {
throw new Error('No stable release tags with docs-site/package.json found.');
}
process.stdout.write(JSON.stringify(buildVersionManifest({ latestStable, stableVersions })));
+27
View File
@@ -5,6 +5,8 @@ import { resolve } from 'node:path';
const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8');
const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml');
const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8');
const packageJsonPath = resolve(__dirname, '../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
scripts: Record<string, string>;
@@ -36,3 +38,28 @@ test('ci workflow runs the maintained source coverage lane and uploads lcov outp
assert.match(ciWorkflow, /name: Upload coverage artifact/);
assert.match(ciWorkflow, /path: coverage\/test-src\/lcov\.info/);
});
test('main docs deploy exists, serializes deploys, and uses Cloudflare credentials', () => {
assert.match(docsPagesWorkflow, /name: Docs Pages/);
assert.match(docsPagesWorkflow, /branches:\s*\n\s*-\s*main/);
assert.match(docsPagesWorkflow, /group:\s*docs-pages-production/);
assert.match(docsPagesWorkflow, /CLOUDFLARE_API_TOKEN/);
assert.match(docsPagesWorkflow, /CLOUDFLARE_ACCOUNT_ID/);
assert.match(docsPagesWorkflow, /CLOUDFLARE_PAGES_PROJECT_NAME/);
assert.match(docsPagesWorkflow, /pages deploy \.tmp\/docs-versioned-site/);
assert.match(docsPagesWorkflow, /--branch main/);
});
test('docs deploy caches stable archive builds between runs', () => {
assert.match(docsPagesWorkflow, /actions\/cache@v4/);
assert.match(docsPagesWorkflow, /\.tmp\/docs-versioned-archive-cache/);
assert.match(docsPagesWorkflow, /docs-versioned-archives-/);
assert.match(docsPagesWorkflow, /docs-site\/\.vitepress\/\*\*/);
});
test('docs deploy skips invalid release tags without failing the workflow', () => {
assert.match(docsPagesWorkflow, /id:\s*tag_guard/);
assert.match(docsPagesWorkflow, /stable_tag=false/);
assert.doesNotMatch(docsPagesWorkflow, /exit 78/);
assert.match(docsPagesWorkflow, /if:\s*steps\.tag_guard\.outputs\.stable_tag != 'false'/);
});
+8
View File
@@ -212,6 +212,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(settings), true);
assert.equal(shouldRunSettingsOnlyStartup(settings), true);
const configSettings = parseArgs(['--config']);
assert.equal(configSettings.configSettings, true);
assert.equal(hasExplicitCommand(configSettings), true);
assert.equal(shouldStartApp(configSettings), true);
assert.equal(shouldRunSettingsOnlyStartup(configSettings), false);
assert.equal(commandNeedsOverlayRuntime(configSettings), false);
assert.equal(commandNeedsOverlayStartupPrereqs(configSettings), false);
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
assert.equal(settingsWithOverlay.settings, true);
assert.equal(settingsWithOverlay.toggleVisibleOverlay, true);
+7
View File
@@ -11,6 +11,7 @@ export interface CliArgs {
toggleVisibleOverlay: boolean;
togglePrimarySubtitleBar: boolean;
settings: boolean;
configSettings: boolean;
setup: boolean;
show: boolean;
hide: boolean;
@@ -115,6 +116,7 @@ export function parseArgs(argv: string[]): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -234,6 +236,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
else if (arg === '--config') args.configSettings = true;
else if (arg === '--setup') args.setup = true;
else if (arg === '--show') args.show = true;
else if (arg === '--hide') args.hide = true;
@@ -486,6 +489,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.settings ||
args.configSettings ||
args.setup ||
args.show ||
args.hide ||
@@ -558,6 +562,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.settings &&
!args.configSettings &&
!args.setup &&
!args.show &&
!args.hide &&
@@ -625,6 +630,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggleVisibleOverlay ||
args.togglePrimarySubtitleBar ||
args.settings ||
args.configSettings ||
args.setup ||
args.copySubtitle ||
args.copySubtitleMultiple ||
@@ -679,6 +685,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.togglePrimarySubtitleBar &&
!args.configSettings &&
!args.show &&
!args.hide &&
!args.setup &&
+1
View File
@@ -22,6 +22,7 @@ test('printHelp includes configured texthooker port', () => {
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
assert.doesNotMatch(output, /--refresh-known-words/);
assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--config\s+Open configuration window/);
assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);
assert.match(output, /--dictionary/);
+1
View File
@@ -25,6 +25,7 @@ ${B}Overlay${R}
--show-visible-overlay Show subtitle overlay
--hide-visible-overlay Hide subtitle overlay
--settings Open Yomitan settings window
--config Open configuration window
--setup Open first-run setup window
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
+102 -1
View File
@@ -1453,6 +1453,104 @@ test('parses descriptor-based controller bindings', () => {
});
});
test('parses controller profiles as per-gamepad binding overrides', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"buttonIndices": {
"buttonSouth": 0,
"leftTrigger": 6
},
"bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
"quitMpv": "leftTrigger"
},
"profiles": {
"8BitDo SN30": {
"label": "8BitDo SN30",
"bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": 11 },
"leftStickVertical": { "kind": "axis", "axisIndex": 7, "dpadFallback": "none" }
}
},
"Xbox Wireless Controller": {
"buttonIndices": {
"leftTrigger": 8
},
"bindings": {
"quitMpv": "leftTrigger"
}
}
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.toggleLookup, {
kind: 'button',
buttonIndex: 11,
});
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.closeLookup, {
kind: 'button',
buttonIndex: 1,
});
assert.deepEqual(config.controller.profiles['8BitDo SN30']?.bindings.leftStickVertical, {
kind: 'axis',
axisIndex: 7,
dpadFallback: 'none',
});
assert.deepEqual(config.controller.profiles['Xbox Wireless Controller']?.bindings.quitMpv, {
kind: 'button',
buttonIndex: 8,
});
assert.equal(
config.controller.profiles['Xbox Wireless Controller']?.buttonIndices.leftTrigger,
8,
);
assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 });
});
test('rejects reserved controller profile ids from config', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"controller": {
"profiles": {
"__proto__": { "label": "polluted" },
"constructor": { "label": "ctor" },
"prototype": { "label": "proto" },
"pad-1": { "label": "Pad 1" }
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(Object.hasOwn(config.controller.profiles, '__proto__'), false);
assert.equal(Object.hasOwn(config.controller.profiles, 'constructor'), false);
assert.equal(Object.hasOwn(config.controller.profiles, 'prototype'), false);
assert.equal(config.controller.profiles['pad-1']?.label, 'Pad 1');
assert.equal(
warnings.some((warning) => warning.path === 'controller.profiles.constructor'),
true,
);
assert.equal(
warnings.some((warning) => warning.path === 'controller.profiles.prototype'),
true,
);
});
test('controller descriptor config rejects malformed binding objects', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -2226,7 +2324,10 @@ test('template generator includes known keys', () => {
/"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
);
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
assert.match(output, /"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/);
assert.match(
output,
/"openBrowser": false,? \/\/ Open the texthooker page in the default browser when the server starts\. Values: true \| false/,
);
assert.match(
output,
/"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/,
+1
View File
@@ -74,6 +74,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
},
profiles: {},
},
shortcuts: {
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ResolvedConfig } from '../../types/config';
import {
CONFIG_OPTION_REGISTRY,
CONFIG_TEMPLATE_SECTIONS,
@@ -13,6 +14,77 @@ import { buildImmersionConfigOptionRegistry } from './options-immersion';
import { buildIntegrationConfigOptionRegistry } from './options-integrations';
import { buildSubtitleConfigOptionRegistry } from './options-subtitle';
function collectConfigLeafPaths(config: ResolvedConfig): string[] {
const leaves: string[] = [];
const visit = (value: unknown, prefix: string): void => {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
leaves.push(prefix);
return;
}
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) {
leaves.push(prefix);
return;
}
for (const [key, child] of entries) {
visit(child, prefix ? `${prefix}.${key}` : key);
}
};
visit(config, '');
return leaves;
}
// DEFAULT_CONFIG leaves that intentionally do not have a curated
// CONFIG_OPTION_REGISTRY entry. The generated config.example.jsonc still
// includes these paths, but their inline comments fall back to an auto-
// humanized key name instead of a written description.
//
// Current intentional gaps:
// - subtitleStyle.*: thin wrappers around standard CSS properties; the
// CSS reference is the canonical documentation surface.
// - keybindings: an array of {key, command} objects, documented at the
// section level via CONFIG_TEMPLATE_SECTIONS rather than per-leaf.
//
// New leaves added to DEFAULT_CONFIG should prefer a registry entry over
// an allowlist entry. Only allowlist a path when the registry is genuinely
// the wrong surface for it.
const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
'keybindings',
'subtitleStyle.backdropFilter',
'subtitleStyle.backgroundColor',
'subtitleStyle.fontColor',
'subtitleStyle.fontFamily',
'subtitleStyle.fontKerning',
'subtitleStyle.fontSize',
'subtitleStyle.fontStyle',
'subtitleStyle.fontWeight',
'subtitleStyle.jlptColors.N1',
'subtitleStyle.jlptColors.N2',
'subtitleStyle.jlptColors.N3',
'subtitleStyle.jlptColors.N4',
'subtitleStyle.jlptColors.N5',
'subtitleStyle.knownWordColor',
'subtitleStyle.letterSpacing',
'subtitleStyle.lineHeight',
'subtitleStyle.nPlusOneColor',
'subtitleStyle.secondary.backdropFilter',
'subtitleStyle.secondary.backgroundColor',
'subtitleStyle.secondary.fontColor',
'subtitleStyle.secondary.fontFamily',
'subtitleStyle.secondary.fontKerning',
'subtitleStyle.secondary.fontSize',
'subtitleStyle.secondary.fontStyle',
'subtitleStyle.secondary.fontWeight',
'subtitleStyle.secondary.letterSpacing',
'subtitleStyle.secondary.lineHeight',
'subtitleStyle.secondary.textRendering',
'subtitleStyle.secondary.textShadow',
'subtitleStyle.secondary.wordSpacing',
'subtitleStyle.textRendering',
'subtitleStyle.textShadow',
'subtitleStyle.wordSpacing',
]);
test('config option registry includes critical paths and has unique entries', () => {
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
@@ -40,6 +112,35 @@ test('config option registry includes critical paths and has unique entries', ()
assert.equal(new Set(paths).size, paths.length);
});
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
const missing = leaves
.filter((path) => !registryPaths.has(path) && !UNDOCUMENTED_LEAVES.has(path))
.sort();
assert.deepEqual(
missing,
[],
`Add CONFIG_OPTION_REGISTRY entries (preferred) or add to UNDOCUMENTED_LEAVES allowlist: ${missing.join(', ')}`,
);
const stale = [...UNDOCUMENTED_LEAVES].filter((path) => registryPaths.has(path)).sort();
assert.deepEqual(
stale,
[],
`Remove from UNDOCUMENTED_LEAVES (now covered by CONFIG_OPTION_REGISTRY): ${stale.join(', ')}`,
);
const leafSet = new Set(leaves);
const orphaned = [...UNDOCUMENTED_LEAVES].filter((path) => !leafSet.has(path)).sort();
assert.deepEqual(
orphaned,
[],
`Remove from UNDOCUMENTED_LEAVES (no longer a DEFAULT_CONFIG leaf): ${orphaned.join(', ')}`,
);
});
test('config template sections include expected domains and unique keys', () => {
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
const requiredKeys: (typeof keys)[number][] = [
+175
View File
@@ -239,6 +239,13 @@ export function buildCoreConfigOptionRegistry(
description:
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
},
{
path: 'controller.profiles',
kind: 'object',
defaultValue: defaultConfig.controller.profiles,
description:
'Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.',
},
...discreteBindings.flatMap((binding) => [
{
path: `controller.bindings.${binding.id}`,
@@ -315,6 +322,46 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.texthooker.launchAtStartup,
description: 'Launch texthooker server automatically when SubMiner starts.',
},
{
path: 'texthooker.openBrowser',
kind: 'boolean',
defaultValue: defaultConfig.texthooker.openBrowser,
description: 'Open the texthooker page in the default browser when the server starts.',
},
{
path: 'subtitlePosition.yPercent',
kind: 'number',
defaultValue: defaultConfig.subtitlePosition.yPercent,
description:
'Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.',
},
{
path: 'auto_start_overlay',
kind: 'boolean',
defaultValue: defaultConfig.auto_start_overlay,
description: 'Auto-start the subtitle overlay window when SubMiner launches.',
},
{
path: 'secondarySub.secondarySubLanguages',
kind: 'array',
defaultValue: defaultConfig.secondarySub.secondarySubLanguages,
description:
'Language code priority list used to auto-select a secondary subtitle track when available.',
},
{
path: 'secondarySub.autoLoadSecondarySub',
kind: 'boolean',
defaultValue: defaultConfig.secondarySub.autoLoadSecondarySub,
description:
'Automatically load a matching secondary subtitle when the primary subtitle loads.',
},
{
path: 'secondarySub.defaultMode',
kind: 'enum',
enumValues: ['hidden', 'visible', 'hover'],
defaultValue: defaultConfig.secondarySub.defaultMode,
description: 'Default visibility mode for the secondary subtitle bar.',
},
{
path: 'websocket.enabled',
kind: 'enum',
@@ -353,6 +400,27 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.subsync.replace,
description: 'Replace the active subtitle file when sync completes.',
},
{
path: 'subsync.alass_path',
kind: 'string',
defaultValue: defaultConfig.subsync.alass_path,
description:
'Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.',
},
{
path: 'subsync.ffsubsync_path',
kind: 'string',
defaultValue: defaultConfig.subsync.ffsubsync_path,
description:
'Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.',
},
{
path: 'subsync.ffmpeg_path',
kind: 'string',
defaultValue: defaultConfig.subsync.ffmpeg_path,
description:
'Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.',
},
{
path: 'startupWarmups.lowPowerMode',
kind: 'boolean',
@@ -415,5 +483,112 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.multiCopyTimeoutMs,
description: 'Timeout for multi-copy/mine modes.',
},
{
path: 'shortcuts.toggleVisibleOverlayGlobal',
kind: 'string',
defaultValue: defaultConfig.shortcuts.toggleVisibleOverlayGlobal,
description:
'Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.',
},
{
path: 'shortcuts.copySubtitle',
kind: 'string',
defaultValue: defaultConfig.shortcuts.copySubtitle,
description: 'Accelerator that copies the current subtitle line to the clipboard.',
},
{
path: 'shortcuts.copySubtitleMultiple',
kind: 'string',
defaultValue: defaultConfig.shortcuts.copySubtitleMultiple,
description:
'Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.',
},
{
path: 'shortcuts.updateLastCardFromClipboard',
kind: 'string',
defaultValue: defaultConfig.shortcuts.updateLastCardFromClipboard,
description:
'Accelerator that updates the last mined Anki card using the current clipboard contents.',
},
{
path: 'shortcuts.triggerFieldGrouping',
kind: 'string',
defaultValue: defaultConfig.shortcuts.triggerFieldGrouping,
description: 'Accelerator that triggers Kiku field grouping on duplicate cards.',
},
{
path: 'shortcuts.triggerSubsync',
kind: 'string',
defaultValue: defaultConfig.shortcuts.triggerSubsync,
description: 'Accelerator that triggers subsync against the active subtitle file.',
},
{
path: 'shortcuts.mineSentence',
kind: 'string',
defaultValue: defaultConfig.shortcuts.mineSentence,
description: 'Accelerator that mines the current sentence as a new Anki card.',
},
{
path: 'shortcuts.mineSentenceMultiple',
kind: 'string',
defaultValue: defaultConfig.shortcuts.mineSentenceMultiple,
description:
'Accelerator that mines consecutive sentences while the multi-mine window stays open.',
},
{
path: 'shortcuts.toggleSecondarySub',
kind: 'string',
defaultValue: defaultConfig.shortcuts.toggleSecondarySub,
description: 'Accelerator that toggles the secondary subtitle bar visibility.',
},
{
path: 'shortcuts.markAudioCard',
kind: 'string',
defaultValue: defaultConfig.shortcuts.markAudioCard,
description: 'Accelerator that marks the last mined card as an audio card.',
},
{
path: 'shortcuts.openCharacterDictionary',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openCharacterDictionary,
description: 'Accelerator that opens the character dictionary modal.',
},
{
path: 'shortcuts.openRuntimeOptions',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openRuntimeOptions,
description: 'Accelerator that opens the runtime options modal.',
},
{
path: 'shortcuts.openJimaku',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openJimaku,
description: 'Accelerator that opens the Jimaku subtitle search modal.',
},
{
path: 'shortcuts.openSessionHelp',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openSessionHelp,
description: 'Accelerator that opens the session help / keybinding cheatsheet.',
},
{
path: 'shortcuts.openControllerSelect',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openControllerSelect,
description: 'Accelerator that opens the controller selection and learn-mode modal.',
},
{
path: 'shortcuts.openControllerDebug',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openControllerDebug,
description:
'Accelerator that opens the controller debug modal with live axis/button readouts.',
},
{
path: 'shortcuts.toggleSubtitleSidebar',
kind: 'string',
defaultValue: defaultConfig.shortcuts.toggleSubtitleSidebar,
description: 'Accelerator that toggles the subtitle sidebar visibility.',
},
];
}
@@ -15,6 +15,12 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.enabled,
description: 'Enable AnkiConnect integration.',
},
{
path: 'ankiConnect.url',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.url,
description: 'Base URL of the AnkiConnect HTTP server.',
},
{
path: 'ankiConnect.pollingRate',
kind: 'number',
@@ -58,6 +64,37 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.fields.word,
description: 'Card field for the mined word or expression text.',
},
{
path: 'ankiConnect.fields.audio',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.fields.audio,
description: 'Card field that receives generated sentence audio.',
},
{
path: 'ankiConnect.fields.image',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.fields.image,
description: 'Card field that receives the captured screenshot or animated image.',
},
{
path: 'ankiConnect.fields.sentence',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.fields.sentence,
description: 'Card field that receives the source sentence text.',
},
{
path: 'ankiConnect.fields.miscInfo',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.fields.miscInfo,
description:
'Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).',
},
{
path: 'ankiConnect.fields.translation',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.fields.translation,
description: 'Card field that receives the current selection or translated text.',
},
{
path: 'ankiConnect.ai.enabled',
kind: 'boolean',
@@ -83,6 +120,41 @@ export function buildIntegrationConfigOptionRegistry(
description: 'Automatically update newly added cards.',
runtime: runtimeOptionById.get('anki.autoUpdateNewCards'),
},
{
path: 'ankiConnect.behavior.overwriteAudio',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.behavior.overwriteAudio,
description:
'When updating an existing card, overwrite the audio field instead of skipping it.',
},
{
path: 'ankiConnect.behavior.overwriteImage',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.behavior.overwriteImage,
description:
'When updating an existing card, overwrite the image field instead of skipping it.',
},
{
path: 'ankiConnect.behavior.mediaInsertMode',
kind: 'enum',
enumValues: ['append', 'prepend'],
defaultValue: defaultConfig.ankiConnect.behavior.mediaInsertMode,
description:
'Whether new media is appended after or prepended before existing field contents on update.',
},
{
path: 'ankiConnect.behavior.highlightWord',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.behavior.highlightWord,
description: 'Bold the mined word inside the sentence field on the saved Anki card.',
},
{
path: 'ankiConnect.behavior.notificationType',
kind: 'enum',
enumValues: ['osd', 'system', 'both', 'none'],
defaultValue: defaultConfig.ankiConnect.behavior.notificationType,
description: 'Notification surface used to announce mining and update outcomes.',
},
{
path: 'ankiConnect.media.syncAnimatedImageToWordAudio',
kind: 'boolean',
@@ -90,6 +162,97 @@ export function buildIntegrationConfigOptionRegistry(
description:
'For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio.',
},
{
path: 'ankiConnect.media.generateAudio',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
description: 'Generate sentence audio for mined cards.',
},
{
path: 'ankiConnect.media.generateImage',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.media.generateImage,
description: 'Generate screenshot or animated image for mined cards.',
},
{
path: 'ankiConnect.media.imageType',
kind: 'enum',
enumValues: ['static', 'avif'],
defaultValue: defaultConfig.ankiConnect.media.imageType,
description:
'Image capture type: "static" for a single still frame, "avif" for an animated AVIF.',
},
{
path: 'ankiConnect.media.imageFormat',
kind: 'enum',
enumValues: ['jpg', 'png', 'webp'],
defaultValue: defaultConfig.ankiConnect.media.imageFormat,
description: 'Encoding format used when imageType is "static".',
},
{
path: 'ankiConnect.media.imageQuality',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.imageQuality,
description: 'Quality (0-100) used for lossy static image encoders.',
},
{
path: 'ankiConnect.media.imageMaxWidth',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.imageMaxWidth,
description:
'Optional maximum width for static images. Leave unset to preserve the source resolution.',
},
{
path: 'ankiConnect.media.imageMaxHeight',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.imageMaxHeight,
description:
'Optional maximum height for static images. Leave unset to preserve the source resolution.',
},
{
path: 'ankiConnect.media.animatedFps',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.animatedFps,
description: 'Target frame rate for animated AVIF captures.',
},
{
path: 'ankiConnect.media.animatedMaxWidth',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.animatedMaxWidth,
description: 'Maximum width applied to animated AVIF captures.',
},
{
path: 'ankiConnect.media.animatedMaxHeight',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.animatedMaxHeight,
description:
'Optional maximum height for animated AVIF captures. Leave unset to preserve aspect ratio.',
},
{
path: 'ankiConnect.media.animatedCrf',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.animatedCrf,
description:
'Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.',
},
{
path: 'ankiConnect.media.audioPadding',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.audioPadding,
description: 'Seconds of padding appended to both ends of generated sentence audio.',
},
{
path: 'ankiConnect.media.fallbackDuration',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.fallbackDuration,
description: 'Fallback clip duration in seconds when subtitle timing data is unavailable.',
},
{
path: 'ankiConnect.media.maxMediaDuration',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration,
description: 'Maximum allowed media clip duration in seconds.',
},
{
path: 'ankiConnect.knownWords.matchMode',
kind: 'enum',
@@ -148,6 +311,44 @@ export function buildIntegrationConfigOptionRegistry(
description: 'Kiku duplicate-card field grouping mode.',
runtime: runtimeOptionById.get('anki.kikuFieldGrouping'),
},
{
path: 'ankiConnect.isKiku.enabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.isKiku.enabled,
description: 'Enable Kiku-specific mining behaviors (duplicate handling, field grouping).',
},
{
path: 'ankiConnect.isKiku.deleteDuplicateInAuto',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.isKiku.deleteDuplicateInAuto,
description:
'When Kiku field grouping is "auto", delete the duplicate source card after grouping completes.',
},
{
path: 'ankiConnect.isLapis.enabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.isLapis.enabled,
description: 'Enable Lapis-specific mining behaviors and sentence card model targeting.',
},
{
path: 'ankiConnect.isLapis.sentenceCardModel',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
description: 'Note type name used by Lapis sentence cards.',
},
{
path: 'ankiConnect.metadata.pattern',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.metadata.pattern,
description:
'Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).',
},
{
path: 'jimaku.apiBaseUrl',
kind: 'string',
defaultValue: defaultConfig.jimaku.apiBaseUrl,
description: 'Base URL of the Jimaku subtitle search API.',
},
{
path: 'jimaku.languagePreference',
kind: 'enum',
@@ -277,6 +478,26 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jellyfin.username,
description: 'Default Jellyfin username used during CLI login.',
},
{
path: 'jellyfin.deviceId',
kind: 'string',
defaultValue: defaultConfig.jellyfin.deviceId,
description:
'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.',
},
{
path: 'jellyfin.clientName',
kind: 'string',
defaultValue: defaultConfig.jellyfin.clientName,
description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.',
},
{
path: 'jellyfin.clientVersion',
kind: 'string',
defaultValue: defaultConfig.jellyfin.clientVersion,
description:
'Client version sent on the Jellyfin authentication handshake; primarily internal.',
},
{
path: 'jellyfin.defaultLibraryId',
kind: 'string',
@@ -387,6 +608,18 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ai.baseUrl,
description: 'Base URL for the shared OpenAI-compatible AI provider.',
},
{
path: 'ai.model',
kind: 'string',
defaultValue: defaultConfig.ai.model,
description: 'Default model identifier requested from the shared AI provider.',
},
{
path: 'ai.systemPrompt',
kind: 'string',
defaultValue: defaultConfig.ai.systemPrompt,
description: 'Default system prompt sent with shared AI provider requests.',
},
{
path: 'ai.requestTimeoutMs',
kind: 'number',
+423
View File
@@ -0,0 +1,423 @@
import type {
ControllerAxisBinding,
ControllerAxisDirection,
ControllerButtonBinding,
ControllerButtonIndicesConfig,
ControllerDpadFallback,
ResolvedControllerAxisBinding,
ResolvedControllerBindingsConfig,
ResolvedControllerDiscreteBinding,
} from '../../types/runtime';
import { ResolveContext } from './context';
import { asBoolean, asNumber, asString, isObject } from './shared';
const CONTROLLER_BUTTON_BINDINGS = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const CONTROLLER_AXIS_BINDINGS = [
'leftStickX',
'leftStickY',
'rightStickX',
'rightStickY',
] as const;
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
leftStickX: 0,
leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
};
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
Exclude<ControllerButtonBinding, 'none'>,
keyof Required<ControllerButtonIndicesConfig>
> = {
select: 'select',
buttonSouth: 'buttonSouth',
buttonEast: 'buttonEast',
buttonNorth: 'buttonNorth',
buttonWest: 'buttonWest',
leftShoulder: 'leftShoulder',
rightShoulder: 'rightShoulder',
leftStickPress: 'leftStickPress',
rightStickPress: 'rightStickPress',
leftTrigger: 'leftTrigger',
rightTrigger: 'rightTrigger',
};
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
leftStickHorizontal: 'horizontal',
leftStickVertical: 'vertical',
rightStickHorizontal: 'none',
rightStickVertical: 'none',
} as const satisfies Record<string, ControllerDpadFallback>;
const CONTROLLER_BUTTON_INDEX_KEYS = [
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const CONTROLLER_DISCRETE_BINDING_KEYS = [
'toggleLookup',
'closeLookup',
'toggleKeyboardOnlyMode',
'mineCard',
'quitMpv',
'previousAudio',
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
] as const;
const CONTROLLER_AXIS_BINDING_KEYS = [
'leftStickHorizontal',
'leftStickVertical',
'rightStickHorizontal',
'rightStickVertical',
] as const;
const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
type ControllerBindingsTarget = Required<ResolvedControllerBindingsConfig>;
type ControllerButtonIndicesTarget = Required<ControllerButtonIndicesConfig>;
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
return value === 'negative' || value === 'positive';
}
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
return value === 'none' || value === 'horizontal' || value === 'vertical';
}
function resolveLegacyDiscreteBinding(
value: ControllerButtonBinding,
buttonIndices: Required<ControllerButtonIndicesConfig>,
): ResolvedControllerDiscreteBinding {
if (value === 'none') {
return { kind: 'none' };
}
return {
kind: 'button',
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
};
}
function resolveLegacyAxisBinding(
value: ControllerAxisBinding,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding {
return {
kind: 'axis',
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
if (!isObject(value) || typeof value.kind !== 'string') return null;
if (value.kind === 'none') {
return { kind: 'none' };
}
if (value.kind === 'button') {
return typeof value.buttonIndex === 'number' &&
Number.isInteger(value.buttonIndex) &&
value.buttonIndex >= 0
? { kind: 'button', buttonIndex: value.buttonIndex }
: null;
}
if (value.kind === 'axis') {
return typeof value.axisIndex === 'number' &&
Number.isInteger(value.axisIndex) &&
value.axisIndex >= 0 &&
isControllerAxisDirection(value.direction)
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
: null;
}
return null;
}
function parseAxisBindingObject(
value: unknown,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding | null {
if (isObject(value) && value.kind === 'none') {
return { kind: 'none' };
}
if (!isObject(value) || value.kind !== 'axis') return null;
if (
typeof value.axisIndex !== 'number' ||
!Number.isInteger(value.axisIndex) ||
value.axisIndex < 0
) {
return null;
}
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
return null;
}
return {
kind: 'axis',
axisIndex: value.axisIndex,
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
function applyControllerButtonIndices(
source: unknown,
target: ControllerButtonIndicesTarget,
pathPrefix: string,
warn: ResolveContext['warn'],
): void {
if (!isObject(source)) return;
for (const key of CONTROLLER_BUTTON_INDEX_KEYS) {
const value = asNumber(source[key]);
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
target[key] = value;
} else if (source[key] !== undefined) {
warn(`${pathPrefix}.${key}`, source[key], target[key], 'Expected non-negative integer.');
}
}
}
function applyControllerBindings(
source: unknown,
target: ControllerBindingsTarget,
buttonIndices: ControllerButtonIndicesTarget,
pathPrefix: string,
warn: ResolveContext['warn'],
): void {
if (!isObject(source)) return;
for (const key of CONTROLLER_DISCRETE_BINDING_KEYS) {
const bindingValue = source[key];
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_BUTTON_BINDINGS.includes(
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
)
) {
target[key] = resolveLegacyDiscreteBinding(
legacyValue as ControllerButtonBinding,
buttonIndices,
);
continue;
}
const parsedObject = parseDiscreteBindingObject(bindingValue);
if (parsedObject) {
target[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`${pathPrefix}.${key}`,
bindingValue,
target[key],
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
);
}
}
for (const key of CONTROLLER_AXIS_BINDING_KEYS) {
const bindingValue = source[key];
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number])
) {
target[key] = resolveLegacyAxisBinding(legacyValue as ControllerAxisBinding, key);
continue;
}
if (legacyValue === 'none') {
target[key] = { kind: 'none' };
continue;
}
const parsedObject = parseAxisBindingObject(bindingValue, key);
if (parsedObject) {
target[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`${pathPrefix}.${key}`,
bindingValue,
target[key],
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
);
}
}
}
export function applyControllerConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (!isObject(src.controller)) return;
const enabled = asBoolean(src.controller.enabled);
if (enabled !== undefined) {
resolved.controller.enabled = enabled;
} else if (src.controller.enabled !== undefined) {
warn(
'controller.enabled',
src.controller.enabled,
resolved.controller.enabled,
'Expected boolean.',
);
}
const preferredGamepadId = asString(src.controller.preferredGamepadId);
if (preferredGamepadId !== undefined) {
resolved.controller.preferredGamepadId = preferredGamepadId;
}
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
if (preferredGamepadLabel !== undefined) {
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
}
const smoothScroll = asBoolean(src.controller.smoothScroll);
if (smoothScroll !== undefined) {
resolved.controller.smoothScroll = smoothScroll;
} else if (src.controller.smoothScroll !== undefined) {
warn(
'controller.smoothScroll',
src.controller.smoothScroll,
resolved.controller.smoothScroll,
'Expected boolean.',
);
}
const triggerInputMode = asString(src.controller.triggerInputMode);
if (
triggerInputMode === 'auto' ||
triggerInputMode === 'digital' ||
triggerInputMode === 'analog'
) {
resolved.controller.triggerInputMode = triggerInputMode;
} else if (src.controller.triggerInputMode !== undefined) {
warn(
'controller.triggerInputMode',
src.controller.triggerInputMode,
resolved.controller.triggerInputMode,
"Expected 'auto', 'digital', or 'analog'.",
);
}
const boundedNumberKeys = [
'scrollPixelsPerSecond',
'horizontalJumpPixels',
'repeatDelayMs',
'repeatIntervalMs',
] as const;
for (const key of boundedNumberKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && Math.floor(value) > 0) {
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected positive number.',
);
}
}
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
for (const key of deadzoneKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && value >= 0 && value <= 1) {
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected number between 0 and 1.',
);
}
}
applyControllerButtonIndices(
src.controller.buttonIndices,
resolved.controller.buttonIndices,
'controller.buttonIndices',
warn,
);
applyControllerBindings(
src.controller.bindings,
resolved.controller.bindings,
resolved.controller.buttonIndices,
'controller.bindings',
warn,
);
if (isObject(src.controller.profiles)) {
for (const [profileId, rawProfile] of Object.entries(src.controller.profiles)) {
if (RESERVED_CONTROLLER_PROFILE_IDS.has(profileId)) {
warn(
`controller.profiles.${profileId}`,
rawProfile,
undefined,
'Reserved profile id is not allowed.',
);
continue;
}
if (!isObject(rawProfile)) {
warn(
`controller.profiles.${profileId}`,
rawProfile,
undefined,
'Expected controller profile object.',
);
continue;
}
const label = asString(rawProfile.label);
if (rawProfile.label !== undefined && label === undefined) {
warn(
`controller.profiles.${profileId}.label`,
rawProfile.label,
profileId,
'Expected string.',
);
}
const profile = {
label: label ?? profileId,
buttonIndices: structuredClone(resolved.controller.buttonIndices),
bindings: structuredClone(resolved.controller.bindings),
};
applyControllerButtonIndices(
rawProfile.buttonIndices,
profile.buttonIndices,
`controller.profiles.${profileId}.buttonIndices`,
warn,
);
applyControllerBindings(
rawProfile.bindings,
profile.bindings,
profile.buttonIndices,
`controller.profiles.${profileId}.bindings`,
warn,
);
resolved.controller.profiles[profileId] = profile;
}
}
}
+2 -341
View File
@@ -1,150 +1,7 @@
import type {
ControllerAxisBinding,
ControllerAxisBindingConfig,
ControllerAxisDirection,
ControllerButtonBinding,
ControllerButtonIndicesConfig,
ControllerDpadFallback,
ControllerDiscreteBindingConfig,
ResolvedControllerAxisBinding,
ResolvedControllerDiscreteBinding,
} from '../../types/runtime';
import { ResolveContext } from './context';
import { applyControllerConfig } from './controller';
import { asBoolean, asNumber, asString, isObject } from './shared';
const CONTROLLER_BUTTON_BINDINGS = [
'none',
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
const CONTROLLER_AXIS_BINDINGS = [
'leftStickX',
'leftStickY',
'rightStickX',
'rightStickY',
] as const;
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
leftStickX: 0,
leftStickY: 1,
rightStickX: 3,
rightStickY: 4,
};
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
Exclude<ControllerButtonBinding, 'none'>,
keyof Required<ControllerButtonIndicesConfig>
> = {
select: 'select',
buttonSouth: 'buttonSouth',
buttonEast: 'buttonEast',
buttonNorth: 'buttonNorth',
buttonWest: 'buttonWest',
leftShoulder: 'leftShoulder',
rightShoulder: 'rightShoulder',
leftStickPress: 'leftStickPress',
rightStickPress: 'rightStickPress',
leftTrigger: 'leftTrigger',
rightTrigger: 'rightTrigger',
};
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
leftStickHorizontal: 'horizontal',
leftStickVertical: 'vertical',
rightStickHorizontal: 'none',
rightStickVertical: 'none',
} as const satisfies Record<string, ControllerDpadFallback>;
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
return value === 'negative' || value === 'positive';
}
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
return value === 'none' || value === 'horizontal' || value === 'vertical';
}
function resolveLegacyDiscreteBinding(
value: ControllerButtonBinding,
buttonIndices: Required<ControllerButtonIndicesConfig>,
): ResolvedControllerDiscreteBinding {
if (value === 'none') {
return { kind: 'none' };
}
return {
kind: 'button',
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
};
}
function resolveLegacyAxisBinding(
value: ControllerAxisBinding,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding {
return {
kind: 'axis',
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
if (!isObject(value) || typeof value.kind !== 'string') return null;
if (value.kind === 'none') {
return { kind: 'none' };
}
if (value.kind === 'button') {
return typeof value.buttonIndex === 'number' &&
Number.isInteger(value.buttonIndex) &&
value.buttonIndex >= 0
? { kind: 'button', buttonIndex: value.buttonIndex }
: null;
}
if (value.kind === 'axis') {
return typeof value.axisIndex === 'number' &&
Number.isInteger(value.axisIndex) &&
value.axisIndex >= 0 &&
isControllerAxisDirection(value.direction)
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
: null;
}
return null;
}
function parseAxisBindingObject(
value: unknown,
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
): ResolvedControllerAxisBinding | null {
if (isObject(value) && value.kind === 'none') {
return { kind: 'none' };
}
if (!isObject(value) || value.kind !== 'axis') return null;
if (
typeof value.axisIndex !== 'number' ||
!Number.isInteger(value.axisIndex) ||
value.axisIndex < 0
) {
return null;
}
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
return null;
}
return {
kind: 'axis',
axisIndex: value.axisIndex,
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
};
}
export function applyCoreDomainConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
@@ -245,203 +102,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.controller)) {
const enabled = asBoolean(src.controller.enabled);
if (enabled !== undefined) {
resolved.controller.enabled = enabled;
} else if (src.controller.enabled !== undefined) {
warn(
'controller.enabled',
src.controller.enabled,
resolved.controller.enabled,
'Expected boolean.',
);
}
const preferredGamepadId = asString(src.controller.preferredGamepadId);
if (preferredGamepadId !== undefined) {
resolved.controller.preferredGamepadId = preferredGamepadId;
}
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
if (preferredGamepadLabel !== undefined) {
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
}
const smoothScroll = asBoolean(src.controller.smoothScroll);
if (smoothScroll !== undefined) {
resolved.controller.smoothScroll = smoothScroll;
} else if (src.controller.smoothScroll !== undefined) {
warn(
'controller.smoothScroll',
src.controller.smoothScroll,
resolved.controller.smoothScroll,
'Expected boolean.',
);
}
const triggerInputMode = asString(src.controller.triggerInputMode);
if (
triggerInputMode === 'auto' ||
triggerInputMode === 'digital' ||
triggerInputMode === 'analog'
) {
resolved.controller.triggerInputMode = triggerInputMode;
} else if (src.controller.triggerInputMode !== undefined) {
warn(
'controller.triggerInputMode',
src.controller.triggerInputMode,
resolved.controller.triggerInputMode,
"Expected 'auto', 'digital', or 'analog'.",
);
}
const boundedNumberKeys = [
'scrollPixelsPerSecond',
'horizontalJumpPixels',
'repeatDelayMs',
'repeatIntervalMs',
] as const;
for (const key of boundedNumberKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && Math.floor(value) > 0) {
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected positive number.',
);
}
}
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
for (const key of deadzoneKeys) {
const value = asNumber(src.controller[key]);
if (value !== undefined && value >= 0 && value <= 1) {
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
} else if (src.controller[key] !== undefined) {
warn(
`controller.${key}`,
src.controller[key],
resolved.controller[key],
'Expected number between 0 and 1.',
);
}
}
if (isObject(src.controller.buttonIndices)) {
const buttonIndexKeys = [
'select',
'buttonSouth',
'buttonEast',
'buttonNorth',
'buttonWest',
'leftShoulder',
'rightShoulder',
'leftStickPress',
'rightStickPress',
'leftTrigger',
'rightTrigger',
] as const;
for (const key of buttonIndexKeys) {
const value = asNumber(src.controller.buttonIndices[key]);
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
resolved.controller.buttonIndices[key] = value;
} else if (src.controller.buttonIndices[key] !== undefined) {
warn(
`controller.buttonIndices.${key}`,
src.controller.buttonIndices[key],
resolved.controller.buttonIndices[key],
'Expected non-negative integer.',
);
}
}
}
if (isObject(src.controller.bindings)) {
const buttonBindingKeys = [
'toggleLookup',
'closeLookup',
'toggleKeyboardOnlyMode',
'mineCard',
'quitMpv',
'previousAudio',
'nextAudio',
'playCurrentAudio',
'toggleMpvPause',
] as const;
for (const key of buttonBindingKeys) {
const bindingValue = src.controller.bindings[key];
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_BUTTON_BINDINGS.includes(
legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number],
)
) {
resolved.controller.bindings[key] = resolveLegacyDiscreteBinding(
legacyValue as ControllerButtonBinding,
resolved.controller.buttonIndices,
);
continue;
}
const parsedObject = parseDiscreteBindingObject(bindingValue);
if (parsedObject) {
resolved.controller.bindings[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`controller.bindings.${key}`,
bindingValue,
resolved.controller.bindings[key],
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
);
}
}
const axisBindingKeys = [
'leftStickHorizontal',
'leftStickVertical',
'rightStickHorizontal',
'rightStickVertical',
] as const;
for (const key of axisBindingKeys) {
const bindingValue = src.controller.bindings[key];
const legacyValue = asString(bindingValue);
if (
legacyValue !== undefined &&
CONTROLLER_AXIS_BINDINGS.includes(
legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number],
)
) {
resolved.controller.bindings[key] = resolveLegacyAxisBinding(
legacyValue as ControllerAxisBinding,
key,
);
continue;
}
if (legacyValue === 'none') {
resolved.controller.bindings[key] = { kind: 'none' };
continue;
}
const parsedObject = parseAxisBindingObject(bindingValue, key);
if (parsedObject) {
resolved.controller.bindings[key] = parsedObject;
} else if (bindingValue !== undefined) {
warn(
`controller.bindings.${key}`,
bindingValue,
resolved.controller.bindings[key],
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
);
}
}
}
}
applyControllerConfig(context);
if (Array.isArray(src.keybindings)) {
resolved.keybindings = src.keybindings.filter(
+94
View File
@@ -0,0 +1,94 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parse } from 'jsonc-parser';
import { DEFAULT_CONFIG } from '../definitions';
import { applyConfigSettingsPatchToContent, buildConfigSettingsSnapshot } from './jsonc-edit';
import { buildConfigSettingsRegistry } from './registry';
test('applyConfigSettingsPatchToContent preserves JSONC comments while setting nested values', () => {
const input = `{
// keep this comment
"subtitleStyle": {
"fontSize": 35,
},
}`;
const result = applyConfigSettingsPatchToContent({
content: input,
operations: [
{
op: 'set',
path: 'subtitleStyle.autoPauseVideoOnHover',
value: false,
},
],
previousWarnings: [],
});
assert.equal(result.ok, true);
assert.match(result.content, /keep this comment/);
const parsed = parse(result.content);
assert.equal(parsed.subtitleStyle.autoPauseVideoOnHover, false);
assert.equal(parsed.subtitleStyle.fontSize, 35);
});
test('applyConfigSettingsPatchToContent reset removes explicit path', () => {
const input = `{
"subtitleStyle": {
"fontSize": 41,
"autoPauseVideoOnHover": false
}
}`;
const result = applyConfigSettingsPatchToContent({
content: input,
operations: [{ op: 'reset', path: 'subtitleStyle.autoPauseVideoOnHover' }],
previousWarnings: [],
});
assert.equal(result.ok, true);
const parsed = parse(result.content);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'autoPauseVideoOnHover'), false);
assert.equal(parsed.subtitleStyle.fontSize, 41);
});
test('applyConfigSettingsPatchToContent rejects warnings caused by modified fields', () => {
const result = applyConfigSettingsPatchToContent({
content: '{}',
operations: [
{
op: 'set',
path: 'subtitleStyle.autoPauseVideoOnHover',
value: 'bad',
},
],
previousWarnings: [],
});
assert.equal(result.ok, false);
assert.equal(result.warnings[0]?.path, 'subtitleStyle.autoPauseVideoOnHover');
});
test('buildConfigSettingsSnapshot masks configured secret values', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const snapshot = buildConfigSettingsSnapshot({
configPath: '/tmp/config.jsonc',
rawConfig: {
ai: {
apiKey: 'secret-key',
},
},
resolvedConfig: {
...DEFAULT_CONFIG,
ai: {
...DEFAULT_CONFIG.ai,
apiKey: 'secret-key',
},
},
warnings: [],
fields,
});
const apiKey = snapshot.values['ai.apiKey'];
assert.deepEqual(apiKey, { configured: true });
});
+200
View File
@@ -0,0 +1,200 @@
import {
applyEdits,
modify,
parse as parseJsonc,
type FormattingOptions,
type ParseError,
} from 'jsonc-parser';
import type { ConfigValidationWarning, RawConfig, ResolvedConfig } from '../../types/config';
import type {
ConfigSettingsField,
ConfigSettingsPatchOperation,
ConfigSettingsSnapshot,
} from '../../types/settings';
import { resolveConfig } from '../resolve';
import { getConfigValueAtPath } from './registry';
const JSONC_FORMATTING_OPTIONS: FormattingOptions = {
insertSpaces: true,
tabSize: 2,
eol: '\n',
};
export type ConfigSettingsPatchApplyResult =
| {
ok: true;
content: string;
rawConfig: RawConfig;
resolvedConfig: ResolvedConfig;
warnings: ConfigValidationWarning[];
}
| {
ok: false;
content: string;
warnings: ConfigValidationWarning[];
error: string;
};
interface ApplyConfigSettingsPatchOptions {
content: string;
operations: ConfigSettingsPatchOperation[];
previousWarnings: ConfigValidationWarning[];
}
interface BuildConfigSettingsSnapshotOptions {
configPath: string;
rawConfig: RawConfig;
resolvedConfig: ResolvedConfig;
warnings: ConfigValidationWarning[];
fields: ConfigSettingsField[];
}
function pathToSegments(path: string): string[] {
return path.split('.').filter(Boolean);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function pathStartsWith(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}.`);
}
function warningBelongsToModifiedPath(
warning: ConfigValidationWarning,
operation: ConfigSettingsPatchOperation,
): boolean {
return (
pathStartsWith(warning.path, operation.path) || pathStartsWith(operation.path, warning.path)
);
}
function warningIdentity(warning: ConfigValidationWarning): string {
return `${warning.path}\n${JSON.stringify(warning.value)}\n${warning.message}`;
}
function parseRawConfig(content: string): RawConfig {
const errors: ParseError[] = [];
const parsed = parseJsonc(content || '{}', errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (errors.length > 0) {
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
}
return isRecord(parsed) ? (parsed as RawConfig) : {};
}
function normalizeContent(content: string): string {
return content.trim().length > 0 ? content : '{}\n';
}
function applySingleOperation(content: string, operation: ConfigSettingsPatchOperation): string {
const edits = modify(
content,
pathToSegments(operation.path),
operation.op === 'reset' ? undefined : operation.value,
{
formattingOptions: JSONC_FORMATTING_OPTIONS,
getInsertionIndex: (properties) => properties.length,
},
);
return applyEdits(content, edits);
}
function collectModifiedWarnings(
warnings: ConfigValidationWarning[],
operations: ConfigSettingsPatchOperation[],
previousWarnings: ConfigValidationWarning[],
): ConfigValidationWarning[] {
const previous = new Set(previousWarnings.map(warningIdentity));
return warnings.filter((warning) => {
if (!operations.some((operation) => warningBelongsToModifiedPath(warning, operation))) {
return false;
}
return !previous.has(warningIdentity(warning));
});
}
export function applyConfigSettingsPatchToContent(
options: ApplyConfigSettingsPatchOptions,
): ConfigSettingsPatchApplyResult {
let content = normalizeContent(options.content);
try {
parseRawConfig(content);
} catch (error) {
return {
ok: false,
content,
warnings: [],
error: error instanceof Error ? error.message : 'Invalid JSONC.',
};
}
try {
for (const operation of options.operations) {
content = applySingleOperation(content, operation);
}
const rawConfig = parseRawConfig(content);
const { resolved, warnings } = resolveConfig(rawConfig);
const modifiedWarnings = collectModifiedWarnings(
warnings,
options.operations,
options.previousWarnings,
);
if (modifiedWarnings.length > 0) {
return {
ok: false,
content,
warnings: modifiedWarnings,
error: 'One or more modified settings failed validation.',
};
}
return {
ok: true,
content,
rawConfig,
resolvedConfig: resolved,
warnings,
};
} catch (error) {
return {
ok: false,
content,
warnings: [],
error: error instanceof Error ? error.message : 'Failed to update config content.',
};
}
}
export function buildConfigSettingsSnapshot(
options: BuildConfigSettingsSnapshotOptions,
): ConfigSettingsSnapshot {
const values: Record<string, unknown> = {};
for (const field of options.fields) {
const rawValue = getConfigValueAtPath(options.rawConfig, field.configPath);
const resolvedValue = getConfigValueAtPath(options.resolvedConfig, field.configPath);
if (field.secret) {
values[field.configPath] = {
configured:
(typeof rawValue === 'string' && rawValue.length > 0) ||
(typeof resolvedValue === 'string' && resolvedValue.length > 0),
};
continue;
}
values[field.configPath] = structuredClone(rawValue !== undefined ? rawValue : resolvedValue);
}
return {
configPath: options.configPath,
fields: options.fields,
values,
warnings: options.warnings,
};
}
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_CONFIG } from '../definitions';
import {
buildConfigSettingsRegistry,
getConfigSettingsCoverage,
LEGACY_HIDDEN_CONFIG_PATHS,
} from './registry';
test('config settings registry places hover pause under viewing playback behavior', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const hoverPause = fields.find(
(field) => field.configPath === 'subtitleStyle.autoPauseVideoOnHover',
);
assert.ok(hoverPause);
assert.equal(hoverPause.category, 'viewing');
assert.equal(hoverPause.section, 'Playback pause behavior');
assert.equal(hoverPause.control, 'boolean');
});
test('config settings registry hides legacy and ignored paths from normal fields', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const visiblePaths = new Set(
fields.filter((field) => !field.legacyHidden).map((field) => field.configPath),
);
for (const path of LEGACY_HIDDEN_CONFIG_PATHS) {
assert.equal(visiblePaths.has(path), false, path);
}
assert.equal(visiblePaths.has('controller.buttonIndices'), false);
});
test('config settings registry covers canonical defaults or marks explicit raw-only gaps', () => {
const fields = buildConfigSettingsRegistry(DEFAULT_CONFIG);
const coverage = getConfigSettingsCoverage(DEFAULT_CONFIG, fields);
assert.deepEqual(coverage.uncoveredDefaultPaths, []);
});
+346
View File
@@ -0,0 +1,346 @@
import type { ResolvedConfig } from '../../types/config';
import type {
ConfigSettingsCategory,
ConfigSettingsControl,
ConfigSettingsField,
ConfigSettingsRestartBehavior,
} from '../../types/settings';
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
type Leaf = {
path: string;
value: unknown;
};
export const LEGACY_HIDDEN_CONFIG_PATHS = [
'ankiConnect.deck',
'ankiConnect.wordField',
'ankiConnect.audioField',
'ankiConnect.imageField',
'ankiConnect.sentenceField',
'ankiConnect.miscInfoField',
'ankiConnect.miscInfoPattern',
'ankiConnect.generateAudio',
'ankiConnect.generateImage',
'ankiConnect.imageType',
'ankiConnect.imageFormat',
'ankiConnect.imageQuality',
'ankiConnect.imageMaxWidth',
'ankiConnect.imageMaxHeight',
'ankiConnect.animatedFps',
'ankiConnect.animatedMaxWidth',
'ankiConnect.animatedMaxHeight',
'ankiConnect.animatedCrf',
'ankiConnect.syncAnimatedImageToWordAudio',
'ankiConnect.audioPadding',
'ankiConnect.fallbackDuration',
'ankiConnect.maxMediaDuration',
'ankiConnect.overwriteAudio',
'ankiConnect.overwriteImage',
'ankiConnect.mediaInsertMode',
'ankiConnect.highlightWord',
'ankiConnect.notificationType',
'ankiConnect.autoUpdateNewCards',
'ankiConnect.nPlusOne.highlightEnabled',
'ankiConnect.nPlusOne.refreshMinutes',
'ankiConnect.nPlusOne.matchMode',
'ankiConnect.nPlusOne.decks',
'ankiConnect.nPlusOne.knownWord',
'ankiConnect.behavior.nPlusOneHighlightEnabled',
'ankiConnect.behavior.nPlusOneRefreshMinutes',
'ankiConnect.behavior.nPlusOneMatchMode',
'ankiConnect.isLapis.sentenceCardSentenceField',
'ankiConnect.isLapis.sentenceCardAudioField',
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
'jellyfin.accessToken',
'jellyfin.userId',
'controller.buttonIndices',
] as const;
const EXCLUDED_PREFIXES = ['controller.buttonIndices'] as const;
const JSON_OBJECT_FIELDS = new Set([
'keybindings',
'controller.bindings',
'controller.profiles',
'ankiConnect.knownWords.decks',
]);
const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
const COLOR_SUFFIXES = new Set([
'Color',
'color',
'backgroundColor',
'singleColor',
'knownWordColor',
'nPlusOne',
]);
const OPTION_BY_PATH = new Map(CONFIG_OPTION_REGISTRY.map((entry) => [entry.path, entry]));
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function pathStartsWith(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}.`);
}
function isLegacyHidden(path: string): boolean {
return (
LEGACY_HIDDEN_CONFIG_PATHS.some((hiddenPath) => pathStartsWith(path, hiddenPath)) ||
EXCLUDED_PREFIXES.some((prefix) => pathStartsWith(path, prefix))
);
}
function flattenConfigLeaves(value: unknown, prefix = ''): Leaf[] {
if (JSON_OBJECT_FIELDS.has(prefix)) {
return [{ path: prefix, value }];
}
if (Array.isArray(value)) {
return [{ path: prefix, value }];
}
if (isRecord(value)) {
const entries = Object.entries(value).filter(([, child]) => child !== undefined);
if (entries.length === 0) {
return [{ path: prefix, value }];
}
return entries.flatMap(([key, child]) =>
flattenConfigLeaves(child, prefix ? `${prefix}.${key}` : key),
);
}
return prefix ? [{ path: prefix, value }] : [];
}
function humanizePath(path: string): string {
const key = path.split('.').at(-1) ?? path;
const spaced = key
.replace(/_/g, ' ')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/\bai\b/i, 'AI')
.replace(/\bmpv\b/i, 'mpv')
.replace(/\byomitan\b/i, 'Yomitan')
.replace(/\bjimaku\b/i, 'Jimaku')
.replace(/\banilist\b/i, 'AniList')
.replace(/\banki\b/i, 'Anki');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
function categoryAndSection(path: string): { category: ConfigSettingsCategory; section: string } {
if (
path === 'subtitleStyle.autoPauseVideoOnHover' ||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
path === 'subtitleSidebar.pauseVideoOnHover'
) {
return { category: 'viewing', section: 'Playback pause behavior' };
}
if (
path.startsWith('ankiConnect.knownWords.') ||
path.startsWith('ankiConnect.nPlusOne.') ||
path.startsWith('subtitleStyle.frequencyDictionary.') ||
path.startsWith('subtitleStyle.jlptColors.') ||
path === 'subtitleStyle.enableJlpt' ||
path === 'subtitleStyle.nameMatchEnabled' ||
path === 'subtitleStyle.nameMatchColor'
) {
return { category: 'viewing', section: 'Annotation display' };
}
if (path.startsWith('subtitleStyle.secondary.')) {
return { category: 'viewing', section: 'Secondary subtitle appearance' };
}
if (path.startsWith('subtitleStyle.')) {
return { category: 'viewing', section: 'Primary subtitle appearance' };
}
if (path.startsWith('subtitleSidebar.')) {
return { category: 'viewing', section: 'Subtitle sidebar' };
}
if (path.startsWith('subtitlePosition.') || path.startsWith('secondarySub.')) {
return { category: 'viewing', section: 'Subtitle behavior' };
}
if (path.startsWith('ankiConnect.fields.')) {
return { category: 'mining-anki', section: 'Note fields' };
}
if (path.startsWith('ankiConnect.media.')) {
return { category: 'mining-anki', section: 'Media capture' };
}
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
return { category: 'mining-anki', section: 'Kiku and Lapis' };
}
if (path.startsWith('ankiConnect.ai.')) {
return { category: 'mining-anki', section: 'Anki AI' };
}
if (path.startsWith('ankiConnect.proxy.')) {
return { category: 'mining-anki', section: 'AnkiConnect proxy' };
}
if (path.startsWith('ankiConnect.')) {
return { category: 'mining-anki', section: 'AnkiConnect' };
}
if (
path.startsWith('mpv.') ||
path.startsWith('youtube.') ||
path.startsWith('youtubeSubgen.') ||
path.startsWith('jimaku.') ||
path.startsWith('subsync.')
) {
return { category: 'playback-sources', section: topSection(path) };
}
if (path.startsWith('shortcuts.')) {
return { category: 'input', section: 'Overlay shortcuts' };
}
if (path === 'keybindings') {
return { category: 'input', section: 'MPV keybindings' };
}
if (path.startsWith('controller.')) {
return { category: 'input', section: 'Controller' };
}
if (
path.startsWith('ai.') ||
path.startsWith('anilist.') ||
path.startsWith('yomitan.') ||
path.startsWith('jellyfin.') ||
path.startsWith('discordPresence.') ||
path.startsWith('websocket.') ||
path.startsWith('annotationWebsocket.') ||
path.startsWith('texthooker.')
) {
return { category: 'integrations', section: topSection(path) };
}
if (
path.startsWith('immersionTracking.') ||
path.startsWith('stats.') ||
path.startsWith('updates.') ||
path.startsWith('startupWarmups.') ||
path.startsWith('logging.') ||
path === 'auto_start_overlay'
) {
return { category: 'tracking-app', section: topSection(path) };
}
return { category: 'advanced', section: 'Advanced' };
}
function topSection(path: string): string {
const top = path.split('.')[0] ?? path;
const labels: Record<string, string> = {
ai: 'Shared AI provider',
anilist: 'AniList',
annotationWebsocket: 'Annotation WebSocket',
discordPresence: 'Discord Rich Presence',
immersionTracking: 'Immersion tracking',
jimaku: 'Jimaku',
jellyfin: 'Jellyfin',
logging: 'Logging',
mpv: 'mpv launcher',
stats: 'Stats dashboard',
startupWarmups: 'Startup warmups',
subsync: 'Auto subtitle sync',
texthooker: 'Texthooker',
updates: 'Updates',
websocket: 'WebSocket server',
yomitan: 'Yomitan',
youtube: 'YouTube playback',
youtubeSubgen: 'YouTube subtitle generation',
auto_start_overlay: 'Overlay startup',
};
return labels[top] ?? humanizePath(top);
}
function controlForPath(path: string, value: unknown): ConfigSettingsControl {
if (SECRET_PATHS.has(path)) return 'secret';
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
if (Array.isArray(value)) return 'string-list';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return 'number';
if (typeof value === 'string') {
const leaf = path.split('.').at(-1) ?? path;
if ([...COLOR_SUFFIXES].some((suffix) => leaf.endsWith(suffix))) return 'color';
if (leaf.toLowerCase().includes('prompt')) return 'textarea';
return 'text';
}
return 'json';
}
function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
if (
path === 'keybindings' ||
pathStartsWith(path, 'shortcuts') ||
pathStartsWith(path, 'subtitleStyle') ||
pathStartsWith(path, 'subtitleSidebar') ||
path === 'secondarySub.defaultMode' ||
pathStartsWith(path, 'ankiConnect.ai')
) {
return 'hot-reload';
}
return 'restart';
}
function fieldForLeaf(leaf: Leaf): ConfigSettingsField {
const option = OPTION_BY_PATH.get(leaf.path);
const { category, section } = categoryAndSection(leaf.path);
return {
id: leaf.path,
label: option?.path === leaf.path ? humanizePath(leaf.path) : humanizePath(leaf.path),
description: option?.description ?? `${humanizePath(leaf.path)} setting.`,
configPath: leaf.path,
category,
section,
control: controlForPath(leaf.path, leaf.value),
defaultValue: leaf.value,
...(option?.enumValues ? { enumValues: option.enumValues } : {}),
restartBehavior: restartBehaviorForPath(leaf.path),
advanced:
leaf.path.startsWith('controller.') ||
leaf.path.startsWith('immersionTracking.retention.') ||
leaf.path.startsWith('youtubeSubgen.'),
secret: SECRET_PATHS.has(leaf.path),
};
}
export function buildConfigSettingsRegistry(
defaultConfig: ResolvedConfig = DEFAULT_CONFIG,
): ConfigSettingsField[] {
const leaves = flattenConfigLeaves(defaultConfig).filter((leaf) => !isLegacyHidden(leaf.path));
return leaves.map(fieldForLeaf).sort((a, b) => {
const category = a.category.localeCompare(b.category);
if (category !== 0) return category;
const section = a.section.localeCompare(b.section);
if (section !== 0) return section;
return a.configPath.localeCompare(b.configPath);
});
}
export function getConfigSettingsCoverage(
defaultConfig: ResolvedConfig,
fields: ConfigSettingsField[],
): { uncoveredDefaultPaths: string[] } {
const visibleFields = fields.filter((field) => !field.legacyHidden);
const uncoveredDefaultPaths = flattenConfigLeaves(defaultConfig)
.map((leaf) => leaf.path)
.filter((path) => !isLegacyHidden(path))
.filter(
(path) =>
!visibleFields.some(
(field) =>
field.configPath === path ||
(field.control === 'json' && pathStartsWith(path, field.configPath)),
),
)
.sort();
return { uncoveredDefaultPaths };
}
export function getConfigValueAtPath(root: unknown, path: string): unknown {
let current = root;
for (const segment of path.split('.')) {
if (!isRecord(current)) return undefined;
current = current[segment];
}
return current;
}
@@ -32,10 +32,16 @@ test('anilist update queue enqueues, snapshots, and dequeues success', () => {
const loggerState = createLogger();
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
queue.enqueue('k1', 'Demo', 1);
queue.enqueue('k1', 'Demo', 1, 2);
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 });
assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1');
assert.deepEqual(
{
key: queue.nextReady(Number.MAX_SAFE_INTEGER)?.key,
season: queue.nextReady(Number.MAX_SAFE_INTEGER)?.season,
},
{ key: 'k1', season: 2 },
);
queue.markSuccess('k1');
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
@@ -9,6 +9,7 @@ const MAX_ITEMS = 500;
export interface AnilistQueuedUpdate {
key: string;
title: string;
season?: number | null;
episode: number;
createdAt: number;
attemptCount: number;
@@ -28,7 +29,7 @@ export interface AnilistRetryQueueSnapshot {
}
export interface AnilistUpdateQueue {
enqueue: (key: string, title: string, episode: number) => void;
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
markSuccess: (key: string) => void;
markFailure: (key: string, reason: string, nowMs?: number) => void;
@@ -106,7 +107,7 @@ export function createAnilistUpdateQueue(
load();
return {
enqueue(key: string, title: string, episode: number): void {
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
const existing = pending.find((item) => item.key === key);
if (existing) {
return;
@@ -117,6 +118,7 @@ export function createAnilistUpdateQueue(
pending.push({
key,
title,
season,
episode,
createdAt: Date.now(),
attemptCount: 0,
@@ -265,6 +265,125 @@ test('updateAnilistPostWatchProgress skips when progress already reached', async
}
});
test('updateAnilistPostWatchProgress returns non-retryable error when media is not planning or watching', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 33, episodes: 12, title: { english: 'Missing Show' } }],
},
},
});
}
return createJsonResponse({
data: {
Media: { id: 33, mediaListEntry: null },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Missing Show', 2);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /not in your AniList Planning or Watching list/i);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress prefers season-specific AniList matches', async () => {
const originalFetch = globalThis.fetch;
const searchTerms: string[] = [];
let call = 0;
globalThis.fetch = (async (_input, init) => {
call += 1;
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
if (call === 1) {
searchTerms.push(String(body.variables?.search));
return createJsonResponse({
data: {
Page: {
media: [
{ id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } },
{ id: 101, episodes: 12, title: { english: 'Demo Show' } },
],
},
},
});
}
if (call === 2) {
assert.equal(body.variables?.mediaId, 202);
return createJsonResponse({
data: {
Media: { id: 202, mediaListEntry: null },
},
});
}
return createJsonResponse({
data: {
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
season: 2,
});
assert.deepEqual(searchTerms, ['Demo Show Season 2']);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /not in your AniList Planning or Watching list/i);
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 44, episodes: 12, title: { english: 'Rewatch Show' } }],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: { id: 44, mediaListEntry: { progress: 0, status: 'REPEATING' } },
},
});
}
return createJsonResponse({
data: {
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Rewatch Show', 2);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /marked repeating on AniList/i);
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress returns error when search fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
+61 -5
View File
@@ -18,10 +18,12 @@ export interface AnilistMediaGuess {
export interface AnilistPostWatchUpdateResult {
status: 'updated' | 'skipped' | 'error';
message: string;
retryable?: boolean;
}
export interface AnilistPostWatchUpdateOptions {
rateLimiter?: AnilistRateLimiter;
season?: number | null;
}
interface AnilistGraphQlError {
@@ -156,6 +158,28 @@ function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, ' ');
}
function titleMentionsSeason(title: string, season: number): boolean {
const normalized = normalizeTitle(title);
return (
normalized.includes(`season ${season}`) ||
normalized.includes(`s${String(season).padStart(2, '0')}`) ||
normalized.includes(`s${season}`)
);
}
function buildSearchCandidates(title: string, season: number | null | undefined): string[] {
const trimmed = title.trim();
if (!trimmed) return [];
const candidates =
typeof season === 'number' &&
Number.isInteger(season) &&
season > 1 &&
!titleMentionsSeason(trimmed, season)
? [`${trimmed} Season ${season}`, trimmed]
: [trimmed];
return candidates.filter((candidate, index, all) => all.indexOf(candidate) === index);
}
async function anilistGraphQl<T>(
accessToken: string,
query: string,
@@ -226,6 +250,15 @@ function pickBestSearchResult(
return { id: selected.id, title: selectedTitle };
}
function isUpdateableListStatus(status: string | null | undefined): boolean {
return status === 'CURRENT' || status === 'PLANNING';
}
function formatListStatus(status: string | null | undefined): string {
if (!status) return 'not in your AniList Planning or Watching list';
return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`;
}
export async function guessAnilistMediaInfo(
mediaPath: string | null,
mediaTitle: string | null,
@@ -279,6 +312,11 @@ export async function updateAnilistPostWatchProgress(
episode: number,
options: AnilistPostWatchUpdateOptions = {},
): Promise<AnilistPostWatchUpdateResult> {
let media: NonNullable<NonNullable<AnilistSearchData['Page']>['media']> = [];
let searchError: string | null = null;
let pickTitle = title;
const searchCandidates = buildSearchCandidates(title, options.season);
for (const search of searchCandidates) {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
accessToken,
`
@@ -296,10 +334,20 @@ export async function updateAnilistPostWatchProgress(
}
}
`,
{ search: title },
{ search },
options,
);
const searchError = firstErrorMessage(searchResponse);
searchError = firstErrorMessage(searchResponse);
if (searchError) {
break;
}
media = searchResponse.data?.Page?.media ?? [];
if (media.length > 0) {
pickTitle = search;
break;
}
}
if (searchError) {
return {
status: 'error',
@@ -307,8 +355,7 @@ export async function updateAnilistPostWatchProgress(
};
}
const media = searchResponse.data?.Page?.media ?? [];
const picked = pickBestSearchResult(title, episode, media);
const picked = pickBestSearchResult(pickTitle, episode, media);
if (!picked) {
return { status: 'error', message: 'AniList search returned no matches.' };
}
@@ -337,7 +384,16 @@ export async function updateAnilistPostWatchProgress(
};
}
const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
const entry = entryResponse.data?.Media?.mediaListEntry ?? null;
if (!entry || !isUpdateableListStatus(entry.status)) {
return {
status: 'error',
retryable: false,
message: `AniList update not possible: "${picked.title}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
};
}
const currentProgress = entry.progress ?? 0;
if (typeof currentProgress === 'number' && currentProgress >= episode) {
return {
status: 'skipped',
+1
View File
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
+5
View File
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggle: false,
toggleVisibleOverlay: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
@@ -130,6 +131,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
},
openConfigSettingsWindow: () => {
calls.push('openConfigSettingsWindow');
},
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
},
@@ -582,6 +586,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
expected: string;
}> = [
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
{ args: { configSettings: true }, expected: 'openConfigSettingsWindow' },
{
args: { showVisibleOverlay: true },
expected: 'setVisibleOverlayVisible:true',
+5
View File
@@ -43,6 +43,7 @@ export interface CliCommandServiceDeps {
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
openConfigSettingsWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -160,6 +161,7 @@ interface MiningCliRuntime {
interface UiCliRuntime {
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
printHelp: () => void;
@@ -257,6 +259,7 @@ export function createCliCommandDepsRuntime(
options.ui.openYomitanSettings();
}, delayMs);
},
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
setVisibleOverlayVisible: options.overlay.setVisible,
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
startPendingMultiCopy: options.mining.startPendingMultiCopy,
@@ -385,6 +388,8 @@ export function handleCliCommand(
deps.logDebug('Opened first-run setup flow.');
} else if (args.settings) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.configSettings) {
deps.openConfigSettingsWindow();
} else if (args.show || args.showVisibleOverlay) {
deps.setVisibleOverlayVisible(true);
} else if (args.hide || args.hideVisibleOverlay) {
+85 -1
View File
@@ -83,6 +83,7 @@ function createControllerConfigFixture() {
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
},
profiles: {},
};
}
@@ -218,6 +219,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
onOverlayModalClosed: () => {},
onOverlayMouseInteractionChanged: (active) => {
calls.push(`overlay-interaction:${active}`);
},
openYomitanSettings: () => {},
quitApp: () => {},
toggleVisibleOverlay: () => {},
@@ -281,6 +285,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
deps.clearAnilistToken();
deps.openAnilistSetup();
deps.onOverlayMouseInteractionChanged?.(true, null);
assert.deepEqual(deps.getAnilistQueueStatus(), {
pending: 1,
ready: 0,
@@ -298,10 +303,37 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
assert.deepEqual(calls, [
'clearAnilistToken',
'openAnilistSetup',
'overlay-interaction:true',
'retryAnilistQueueNow',
]);
assert.equal(deps.getPlaybackPaused(), true);
});
test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction active state', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
onOverlayMouseInteractionChanged: (active) => {
calls.push(`overlay-interaction:${active}`);
},
}),
registrar,
);
const handler = handlers.on.get(IPC_CHANNELS.command.setIgnoreMouseEvents);
assert.equal(typeof handler, 'function');
handler?.({}, true, { forward: true });
handler?.({}, false, {});
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
});
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -944,6 +976,58 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
]);
});
test('registerIpcHandlers accepts per-controller profile config updates', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const controllerSaves: unknown[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
saveControllerConfig: async (update) => {
controllerSaves.push(update);
},
}),
registrar,
);
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerConfig);
assert.ok(saveHandler);
const update = {
profiles: {
'pad-1': {
label: 'Pad One',
buttonIndices: {
buttonSouth: 11,
},
bindings: {
toggleLookup: { kind: 'button', buttonIndex: 11 },
leftStickHorizontal: { kind: 'axis', axisIndex: 6, dpadFallback: 'horizontal' },
},
},
},
};
await saveHandler({}, update);
assert.deepEqual(controllerSaves, [update]);
await assert.rejects(async () => {
await saveHandler(
{},
{
profiles: {
'pad-1': {
bindings: {
toggleLookup: { kind: 'axis', axisIndex: 0 },
},
},
},
},
);
}, /Invalid controller config payload/);
await assert.rejects(async () => {
await saveHandler({}, JSON.parse('{"profiles":{"__proto__":{"label":"polluted"}}}'));
}, /Invalid controller config payload/);
});
test('registerIpcHandlers validates dispatchSessionAction payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const dispatched: SessionActionDispatchRequest[] = [];
+10
View File
@@ -44,6 +44,10 @@ export interface IpcServiceDeps {
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayMouseInteractionChanged?: (
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleDevTools: () => void;
@@ -175,6 +179,10 @@ export interface IpcDepsRuntimeOptions {
modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayMouseInteractionChanged?: (
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleVisibleOverlay: () => void;
@@ -233,6 +241,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
return {
onOverlayModalClosed: options.onOverlayModalClosed,
onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
openYomitanSettings: options.openYomitanSettings,
quitApp: options.quitApp,
toggleDevTools: () => {
@@ -349,6 +358,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
}
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
},
);
+351 -6
View File
@@ -42,6 +42,11 @@ function createMainWindowRecorder() {
setAlwaysOnTop: (flag: boolean) => {
calls.push(`always-on-top:${flag}`);
},
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
calls.push(
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
},
@@ -538,11 +543,12 @@ test('forced passthrough still shows tracked overlay while bound to mpv on Windo
assert.ok(calls.includes('sync-windows-z-order'));
});
test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => {
test('forced mouse passthrough keeps macOS tracked overlay above active mpv', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
updateVisibleOverlayVisibility({
@@ -571,8 +577,53 @@ test('forced mouse passthrough drops macOS tracked overlay below higher-priority
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('always-on-top:false'));
});
test('forced mouse passthrough still hides macOS tracked overlay when mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
forceMousePassthrough: true,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
@@ -916,7 +967,8 @@ test('macOS tracked visible overlay starts click-through without passively steal
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
@@ -1009,7 +1061,7 @@ test('macOS keeps active mpv overlay visible and click-through during tracker re
assert.deepEqual(osdMessages, []);
});
test('macOS tracked overlay releases topmost level when mpv loses foreground', () => {
test('macOS tracked overlay hides when mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
@@ -1017,6 +1069,9 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
@@ -1046,14 +1101,202 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('show'));
});
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('hide'));
});
test('macOS lets an active overlay receive mouse input instead of forcing passthrough', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(!calls.includes('mouse-ignore:true:forward'));
assert.ok(!calls.includes('hide'));
});
test('macOS focuses an active overlay so lookup trigger keys reach it', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('focus'));
assert.ok(!calls.includes('hide'));
});
test('macOS tracked overlay passively reappears when mpv regains foreground', () => {
const { window, calls } = createMainWindowRecorder();
let targetFocused = false;
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => targetFocused,
};
window.show();
calls.length = 0;
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
isWindowsPlatform: false,
} as never);
run();
assert.ok(calls.includes('hide'));
calls.length = 0;
targetFocused = true;
run();
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
const { window, calls } = createMainWindowRecorder();
const osdMessages: string[] = [];
@@ -1141,7 +1384,8 @@ test('forced mouse passthrough keeps macOS tracked overlay passive while visible
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
@@ -1438,7 +1682,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
assert.ok(!calls.includes('show'));
});
test('macOS preserves visible overlay level during non-minimized tracker loss', () => {
test('macOS hides visible overlay during tracker loss after mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
@@ -1477,13 +1721,114 @@ test('macOS preserves visible overlay level during non-minimized tracker loss',
},
} as never);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('loading-osd'));
});
test('macOS keeps a focused overlay visible during tracker loss', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
setFocused(true);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {
calls.push('loading-osd');
},
} as never);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('loading-osd'));
});
test('macOS keeps an interactive overlay visible during tracker loss even when Electron focus drops', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
overlayInteractionActive: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: true,
showOverlayLoadingOsd: () => {
calls.push('loading-osd');
},
} as never);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('loading-osd'));
});
+53 -8
View File
@@ -15,6 +15,17 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
opacityCapableWindow.setOpacity?.(opacity);
}
function releaseOverlayWindowLevel(window: BrowserWindow): void {
window.setAlwaysOnTop(false);
const allWorkspacesWindow = window as BrowserWindow & {
setVisibleOnAllWorkspaces?: (
visible: boolean,
options?: { visibleOnFullScreen?: boolean },
) => void;
};
allWorkspacesWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
}
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
if (!pendingTimeout) {
@@ -52,6 +63,7 @@ export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
modalActive?: boolean;
forceMousePassthrough?: boolean;
overlayInteractionActive?: boolean;
mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null;
lastKnownWindowsForegroundProcessName?: string | null;
@@ -78,6 +90,7 @@ export function updateVisibleOverlayVisibility(args: {
}
const mainWindow = args.mainWindow;
const overlayInteractionActive = args.overlayInteractionActive === true;
if (args.modalActive) {
if (args.isWindowsPlatform) {
@@ -93,23 +106,26 @@ export function updateVisibleOverlayVisibility(args: {
const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible();
const isVisibleOverlayFocused =
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
overlayInteractionActive ||
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
const windowTracker = args.windowTracker;
const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
const isTrackedMacOSTargetMinimized =
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
const hasTransientMacOSTrackerLoss =
args.isMacOSPlatform &&
canReportMacOSTargetMinimized &&
!!windowTracker &&
!windowTracker.isTracking() &&
!isTrackedMacOSTargetMinimized &&
trackedMacOSTargetFocused !== false &&
mainWindow.isVisible();
const isTrackedMacOSTargetFocused =
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
? true
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
: (trackedMacOSTargetFocused ?? true);
const shouldReleaseMacOSOverlayLevel =
args.isMacOSPlatform &&
!!args.windowTracker &&
@@ -117,7 +133,7 @@ export function updateVisibleOverlayVisibility(args: {
!isVisibleOverlayFocused &&
!isTrackedMacOSTargetFocused;
// Renderer hover tracking temporarily disables this for subtitle and popup interaction.
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform;
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive;
const shouldDefaultToPassthrough =
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
const windowsForegroundProcessName =
@@ -159,14 +175,22 @@ export function updateVisibleOverlayVisibility(args: {
mainWindow.setIgnoreMouseEvents(false);
}
if (shouldReleaseMacOSOverlayLevel) {
releaseOverlayWindowLevel(mainWindow);
if (wasVisible) {
mainWindow.hide();
}
return false;
}
if (shouldBindTrackedWindowsOverlay) {
// On Windows, z-order is enforced by the OS via the owner window mechanism
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
// without any manual z-order management.
} else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) {
} else if (!forceMousePassthrough || args.isMacOSPlatform) {
args.ensureOverlayWindowLevel(mainWindow);
} else {
mainWindow.setAlwaysOnTop(false);
releaseOverlayWindowLevel(mainWindow);
}
if (!wasVisible) {
const hasWebContents =
@@ -179,16 +203,20 @@ export function updateVisibleOverlayVisibility(args: {
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
// callback will trigger another visibility update when the renderer
// has painted its first frame.
} else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) {
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal(
mainWindow,
shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined,
);
}
} else {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
@@ -209,6 +237,16 @@ export function updateVisibleOverlayVisibility(args: {
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
}
if (
args.isMacOSPlatform &&
overlayInteractionActive &&
!forceMousePassthrough &&
typeof mainWindow.isFocused === 'function' &&
!mainWindow.isFocused()
) {
mainWindow.focus();
}
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
mainWindow.focus();
}
@@ -216,6 +254,11 @@ export function updateVisibleOverlayVisibility(args: {
return !shouldReleaseMacOSOverlayLevel;
};
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
shouldEnforceLayerOrder &&
!args.isWindowsPlatform &&
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
const maybeShowOverlayLoadingOsd = (): void => {
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
return;
@@ -258,7 +301,7 @@ export function updateVisibleOverlayVisibility(args: {
}
args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts();
@@ -290,6 +333,7 @@ export function updateVisibleOverlayVisibility(args: {
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
const hasActiveMacOSTargetSignal =
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
const hasActiveMacOSOverlaySignal = args.isMacOSPlatform && overlayInteractionActive;
const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
const isTrackedMacOSTargetMinimized =
@@ -298,6 +342,7 @@ export function updateVisibleOverlayVisibility(args: {
(args.isMacOSPlatform &&
!isTrackedMacOSTargetMinimized &&
(hasRetainedTrackedGeometry ||
(mainWindow.isVisible() && hasActiveMacOSOverlaySignal) ||
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
(args.isWindowsPlatform &&
@@ -315,7 +360,7 @@ export function updateVisibleOverlayVisibility(args: {
}
args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
args.enforceOverlayLayerOrder();
}
args.syncOverlayShortcuts();
+3 -2
View File
@@ -66,15 +66,16 @@ export function handleOverlayWindowBlurred(options: {
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
ensureOverlayWindowLevel: () => void;
moveWindowTop: () => void;
onWindowsVisibleOverlayBlur?: () => void;
onVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform;
}): boolean {
const platform = options.platform ?? process.platform;
if (platform === 'win32' && options.kind === 'visible') {
options.onWindowsVisibleOverlayBlur?.();
options.onVisibleOverlayBlur?.();
return false;
}
if (platform === 'darwin' && options.kind === 'visible') {
options.onVisibleOverlayBlur?.();
return false;
}
+7 -7
View File
@@ -136,14 +136,14 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
moveWindowTop: () => {
calls.push('move-top');
},
onWindowsVisibleOverlayBlur: () => {
calls.push('windows-visible-blur');
onVisibleOverlayBlur: () => {
calls.push('visible-blur');
},
platform: 'win32',
});
assert.equal(handled, false);
assert.deepEqual(calls, ['windows-visible-blur']);
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
@@ -166,7 +166,7 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visible overlay blur', () => {
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
@@ -179,14 +179,14 @@ test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visib
moveWindowTop: () => {
calls.push('move-top');
},
onWindowsVisibleOverlayBlur: () => {
calls.push('windows-visible-blur');
onVisibleOverlayBlur: () => {
calls.push('visible-blur');
},
platform: 'darwin',
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
+1 -1
View File
@@ -180,7 +180,7 @@ export function createOverlayWindow(
moveWindowTop: () => {
window.moveTop();
},
onWindowsVisibleOverlayBlur:
onVisibleOverlayBlur:
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
});
});
@@ -15,6 +15,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
toggleVisibleOverlay: false,
togglePrimarySubtitleBar: false,
settings: false,
configSettings: false,
setup: false,
show: false,
hide: false,
+19
View File
@@ -9,6 +9,8 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
Partial<Pick<BrowserWindow, 'showInactive'>>;
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
return (
@@ -104,6 +106,23 @@ export function promoteStatsWindowLevel(
window.moveTop();
}
export function presentStatsWindow(
window: StatsWindowPresentationController,
platform: NodeJS.Platform = process.platform,
): void {
if (platform === 'darwin') {
if (window.showInactive) {
window.showInactive();
} else {
window.show();
}
return;
}
window.show();
window.focus();
}
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
query: Record<string, string>;
} {
+43
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowOptions,
presentStatsWindow,
promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput,
@@ -230,3 +231,45 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () =
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']);
});
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
const calls: string[] = [];
presentStatsWindow(
{
show: () => {
calls.push('show');
},
showInactive: () => {
calls.push('show-inactive');
},
focus: () => {
calls.push('focus');
},
} as never,
'darwin',
);
assert.deepEqual(calls, ['show-inactive']);
});
test('presentStatsWindow shows and focuses on non-macOS platforms', () => {
const calls: string[] = [];
presentStatsWindow(
{
show: () => {
calls.push('show');
},
showInactive: () => {
calls.push('show-inactive');
},
focus: () => {
calls.push('focus');
},
} as never,
'linux',
);
assert.deepEqual(calls, ['show', 'focus']);
});
+2 -2
View File
@@ -5,6 +5,7 @@ import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
import {
buildStatsWindowLoadFileOptions,
buildStatsWindowOptions,
presentStatsWindow,
promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput,
@@ -49,14 +50,13 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
const bounds = options.resolveBounds();
let placementBounds = syncStatsWindowBounds(window, bounds);
promoteStatsWindowLevel(window);
window.show();
presentStatsWindow(window);
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
if (
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
) {
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
}
window.focus();
options.onVisibilityChanged?.(true);
promoteStatsWindowLevel(window);
}
+1 -4
View File
@@ -142,10 +142,7 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
return sourceHash === null || targetHash === null || sourceHash !== targetHash;
}
export function ensureExtensionCopy(
sourceDir: string,
userDataPath: string,
): ExtensionCopyResult {
export function ensureExtensionCopy(sourceDir: string, userDataPath: string): ExtensionCopyResult {
if (process.platform === 'win32') {
return { targetDir: sourceDir, copied: false };
}
+6 -3
View File
@@ -15,12 +15,14 @@ import {
test('yomitan settings window uses a close-only menu without app quit', () => {
const calls: string[] = [];
configureYomitanSettingsWindowChrome({
configureYomitanSettingsWindowChrome(
{
isDestroyed: () => false,
close: () => calls.push('close'),
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
} as never, (template) => {
} as never,
(template) => {
calls.push(`menu-label:${template[0]?.label ?? ''}`);
const submenu = template[0]?.submenu;
assert.ok(Array.isArray(submenu));
@@ -29,7 +31,8 @@ test('yomitan settings window uses a close-only menu without app quit', () => {
assert.notEqual(closeItem?.role, 'quit');
closeItem?.click?.({} as never, {} as never, {} as never);
return { id: 'settings-menu' } as never;
});
},
);
assert.deepEqual(calls, ['auto-hide:false', 'menu-label:File', 'close', 'menu:custom']);
});

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