Compare commits

...

13 Commits

Author SHA1 Message Date
sudacode f044877c83 fix(macos): drop target after grace period on repeated tracking misses
- registerTrackingMiss was resetting grace state on every miss, so focus was never released; now starts timer on first miss and drops after grace elapses
- update two tests to assert focus is dropped (not preserved) once grace expires
- add IPC test for setIgnoreMouseEvents → onOverlayMouseInteractionChanged mapping
2026-05-16 19:04:16 -07:00
sudacode fe201a2d2f fix(macos): keep overlay interactive when mpv loses foreground
- Track overlay mouse interaction state via IPC setIgnoreMouseEvents hook
- Skip macOS hide/passthrough when overlayInteractionActive is set
- Focus overlay window so lookup keys reach it during interaction
- Record mpv duration events into AniList media state for threshold checks
2026-05-16 18:48:45 -07:00
sudacode 215e0f804b fix(anilist): pass fresh time-pos to post-watch threshold check
- Thread live mpv time-position through to AniList watched-seconds check
- Prevents missed progress updates when the cached value lags behind playback
2026-05-16 17:48:55 -07:00
sudacode a36e628512 fix(macos): release overlay when mpv loses focus 2026-05-16 17:41:58 -07:00
sudacode b6272b229e fix(character-dictionary): cache AniList media resolution to skip repeat
- Add anilist-resolution-cache.json to persist seriesKey→mediaId mappings
- Skip AniList search when a cached resolution or matching snapshot exists
- Expose readCachedSnapshots and readCachedMediaResolution from cache module
2026-05-16 16:19:22 -07:00
sudacode 3a7d650a70 fix(macos): hide overlay when mpv loses foreground and open stats inacti
- Add "active" Swift helper output when mpv is frontmost but geometry is temporarily unavailable, preserving overlay through transient tracker misses
- Use showInactive for overlay and stats window on macOS to avoid switching Spaces over fullscreen mpv
- Disable autoInstallOnAppQuit to prevent premature Squirrel install before user confirms restart
2026-05-16 15:36:44 -07:00
sudacode 89723e2ccb fix(updater): handle unsupported macOS app updates 2026-05-16 02:05:28 -07:00
sudacode d05e2bd8ec ci(prerelease): use committed release-notes, drop CI claude regen
CI runners don't have Claude Code installed, so the regen step in the
prerelease workflow died on 'claude CLI not found'. Mirror the stable
release model: generate prerelease-notes.md locally (claude -p), commit
it, and have CI just verify and reference the committed file.
2026-05-16 00:54:49 -07:00
sudacode 7484d3c102 ci(prerelease): keep Install Lua before test:fast for workflow assertion 2026-05-16 00:33:19 -07:00
sudacode f78a875ba3 ci(prerelease): install Lua before env suite 2026-05-16 00:23:39 -07:00
sudacode a025652542 chore(release): prepare v0.15.0-beta.1 2026-05-16 00:11:21 -07:00
sudacode 91a01b86a9 feat: add auto update support (#65) 2026-05-16 00:09:14 -07:00
sudacode 105713361e fix(macos): preserve overlay on transient tracker loss; fix subsync modal open (#66) 2026-05-15 20:46:00 -07:00
122 changed files with 4578 additions and 370 deletions
+13 -2
View File
@@ -47,6 +47,13 @@ jobs:
- name: Build (TypeScript check)
run: bun run typecheck
- name: Install Lua
run: |
sudo apt-get update
sudo apt-get install -y lua5.4
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
lua -v
- name: Test suite (source)
run: bun run test:fast
@@ -362,8 +369,12 @@ jobs:
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Generate prerelease notes from pending fragments
run: bun run changelog:prerelease-notes --version "${{ steps.version.outputs.VERSION }}"
- name: Verify committed prerelease notes
run: |
if [ ! -s release/prerelease-notes.md ]; then
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
exit 1
fi
- name: Publish Prerelease
env:
+1
View File
@@ -10,6 +10,7 @@ dist/
release/*
!release/
!release/release-notes.md
!release/prerelease-notes.md
build/yomitan/
coverage/
+3 -3
View File
@@ -20,9 +20,9 @@ MACOS_APP_DIR ?= $(HOME)/Applications
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
# If building from source, the AppImage will typically land in release/.
APPIMAGE_SRC := $(firstword $(wildcard release/SubMiner-*.AppImage))
MACOS_APP_SRC := $(firstword $(wildcard release/*.app release/*/*.app))
MACOS_ZIP_SRC := $(firstword $(wildcard release/SubMiner-*.zip))
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
ifeq ($(OS),Windows_NT)
+4 -3
View File
@@ -217,12 +217,13 @@ Download the latest DMG or ZIP from [GitHub Releases](https://github.com/ksyasud
Also download the `subminer` launcher (recommended):
```bash
sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer \
&& sudo chmod +x /usr/local/bin/subminer
mkdir -p ~/.local/bin
curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o ~/.local/bin/subminer \
&& chmod +x ~/.local/bin/subminer
```
> [!NOTE]
> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory.
> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory. Make sure `~/.local/bin` is on your PATH before installing there.
</details>
+1 -1
View File
@@ -1,4 +1,4 @@
type: added
area: updater
- Added tray and `subminer -u` update checks for SubMiner releases, including app update prompts, launcher/support asset updates, checksum verification, configurable update notifications, and an opt-in prerelease update channel for beta/RC testing.
- Added tray and `subminer -u` update checks for SubMiner releases, including app update prompts, launcher updates, Linux rofi theme updates, checksum verification, configurable update notifications, and an opt-in prerelease update channel for beta/RC testing.
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anilist
- Used fresh mpv time-position and duration events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold.
+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.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed macOS overlay tracking so transient mpv window misses no longer hide the overlay; minimizing mpv still hides it.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed macOS overlay passthrough so mpv controls remain clickable before hovering subtitle bars.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed subtitle sync modal opens so macOS no longer flashes and hides the first modal attempt or leaves stale modal state after syncing.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: updater
- Made Linux `subminer -u` perform release updates from the launcher, independent of any running tray app instance, while reporting `up to date` without downloading assets when the latest release is not newer.
- Limited support asset updates to the Linux rofi theme.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Fixed Linux first-run launcher installs by building the packaged launcher with a valid Bun shebang.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Stopped Linux tray update checks from invoking the native Electron updater, using GitHub release metadata/assets instead so checks do not crash the tray app.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: setup
- First-run setup now recognizes installed macOS launchers in Homebrew or user PATH dirs, while manual setup installs avoid Homebrew-owned directories.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Fixed tray update checks for builds that cannot install native app updates, showing a manual install message instead of a restart prompt that cannot apply the update.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updater
- Bring macOS update dialogs to the front when `subminer --update` is run from the launcher.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: build
- Fixed one-shot `make clean build install` flows so install picks up the AppImage built earlier in the same make invocation.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updates
- Kept signed macOS app updates on the native updater path while preventing eager Squirrel install checks before the user confirms restart.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Fixed `subminer app --setup` so it opens the setup flow when SubMiner is already running in the background.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: setup
- Quit standalone setup app launches after first-run setup finishes, returning the terminal instead of leaving the app process open.
+2 -3
View File
@@ -3,9 +3,8 @@ area: tray
- Kept the tray app running when closing tray-launched Yomitan settings.
- Kept tray-launched Yomitan settings loading from blocking other tray actions.
- Removed the default native app menu from Yomitan settings so File > Quit cannot put the tray app into a stuck quit state.
- Replaced the default native Yomitan settings menu with a close-only menu so closing settings does not quit the tray app.
- Added an in-page close button for Yomitan settings on Hyprland, where native window controls are not available.
- Disabled Yomitan's embedded popup preview in the tray-launched settings window to avoid renderer hangs during normal sidebar navigation.
- Skipped heavy Yomitan settings startup preview, storage, dictionary, and Anki controllers when launched from SubMiner to avoid renderer hangs with large dictionary databases.
- Cached Yomitan settings dictionary metadata after explicit loads to avoid repeated large IndexedDB reads.
- Serialized copied Yomitan extension refreshes so startup cannot race itself and leave extension loading in an error state.
- Fixed tray-launched session help focus handling so the modal can close without mpv running.
+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
{
+8 -6
View File
@@ -155,11 +155,11 @@ chmod +x ~/.local/bin/SubMiner.AppImage
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
chmod +x ~/.local/bin/subminer
# Download launcher support assets used for bundled runtime plugin injection
# Download the optional Linux rofi theme
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.local/share/SubMiner/plugin/subminer
cp -R /tmp/plugin/subminer/. ~/.local/share/SubMiner/plugin/subminer/
mkdir -p ~/.local/share/SubMiner/themes
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
```
The `subminer` launcher is the recommended way to use SubMiner on Linux. It ensures mpv is launched with the correct IPC socket, SubMiner defaults, and the bundled runtime plugin so you don't need to configure `mpv.conf` or install a global mpv plugin.
@@ -174,7 +174,9 @@ subminer -u
subminer --update
```
SubMiner verifies launcher/support asset 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 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.
On Linux, `subminer -u` performs this update from the launcher process, so it does not need to start or IPC into the tray app.
### From Source
@@ -240,7 +242,7 @@ subminer -u
subminer --update
```
SubMiner verifies launcher/support asset downloads against `SHA256SUMS.txt`. If `/usr/local/bin/subminer` is protected, SubMiner shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead of elevating itself.
SubMiner verifies launcher downloads against `SHA256SUMS.txt`. If `/usr/local/bin/subminer` is protected, SubMiner shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead of elevating itself.
::: warning Bun required for the launcher
The `subminer` launcher uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`. Install Bun if you haven't already: `curl -fsSL https://bun.sh/install | bash`.
@@ -269,7 +271,7 @@ Build and install the launcher alongside the app:
make install-macos
```
This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle and rofi theme. To install to `/usr/local/bin` instead (already on the default macOS `PATH`):
This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle. To install to `/usr/local/bin` instead (already on the default macOS `PATH`):
```bash
sudo make install-macos PREFIX=/usr/local
+2
View File
@@ -109,6 +109,8 @@ Use `subminer <subcommand> -h` for command-specific help.
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, and rofi theme even when SubMiner is already running in the tray.
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
## Logging
+4 -1
View File
@@ -55,7 +55,10 @@
`bun run build`
When validating packaged updater output, confirm the platform build writes
`*.yml` and `*.blockmap` files under `release/`.
5. Commit the prerelease prep. Do not run `bun run changelog:build`.
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`.
6. Tag the commit: `git tag v<version>`.
7. Push commit + tag.
+20 -20
View File
@@ -241,36 +241,36 @@ test('dictionary command returns after app handoff starts', () => {
assert.equal(handled, true);
});
test('update command forwards launcher path and waits for response', async () => {
test('update command runs direct Linux release update without launching Electron', async () => {
const context = createContext();
context.args.update = true;
const forwarded: string[][] = [];
const responses: string[] = [];
const calls: string[] = [];
const handled = await runUpdateCommand(context, {
createTempDir: () => '/tmp/subminer-update-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandCaptureOutput: (_appPath, appArgs) => {
forwarded.push(appArgs);
return { status: 0, stdout: '', stderr: '' };
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
waitForUpdateResponse: async (responsePath) => {
responses.push(responsePath);
return { ok: true, status: 'up-to-date', version: '0.15.0' };
runDirectReleaseUpdate: async (request) => {
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
return {
appImage: { status: 'not-found' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
};
},
readMainConfig: () => null,
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(forwarded, [
[
'--update',
'--update-launcher-path',
'/tmp/subminer',
'--update-response-path',
'/tmp/subminer-update-test/response.json',
],
assert.deepEqual(calls, [
'direct:/tmp/subminer.app:/tmp/subminer:stable',
'info:AppImage update: not-found',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
]);
assert.deepEqual(responses, ['/tmp/subminer-update-test/response.json']);
});
test('stats command launches attached app command with response path', async () => {
+140
View File
@@ -0,0 +1,140 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runUpdateCommand } from './update-command';
import type { LauncherCommandContext } from './context';
function makeContext(overrides: Partial<LauncherCommandContext> = {}): LauncherCommandContext {
return {
args: {
update: true,
logLevel: 'warn',
} as LauncherCommandContext['args'],
scriptPath: '/home/kyle/.local/bin/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {} as LauncherCommandContext['pluginRuntimeConfig'],
appPath: '/home/kyle/.local/bin/SubMiner.AppImage',
launcherJellyfinConfig: {} as LauncherCommandContext['launcherJellyfinConfig'],
processAdapter: {
platform: () => 'linux',
} as LauncherCommandContext['processAdapter'],
...overrides,
};
}
test('runUpdateCommand updates directly on Linux without launching Electron', async () => {
const calls: string[] = [];
const handled = await runUpdateCommand(makeContext(), {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
runDirectReleaseUpdate: async (request) => {
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`);
return {
appImage: { status: 'updated' },
launcher: { status: 'updated' },
supportAssets: [{ status: 'skipped' }],
};
},
readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
'info:AppImage update: updated',
'info:Launcher update: updated',
'info:Rofi theme update: skipped',
]);
});
test('runUpdateCommand skips Linux asset replacement when release is not newer', async () => {
const calls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string) => {
calls.push(`fetch:${url}`);
if (!url.endsWith('/releases')) {
throw new Error(`unexpected asset fetch: ${url}`);
}
return {
ok: true,
status: 200,
json: async () => [
{
tag_name: 'v0.14.0',
prerelease: false,
draft: false,
assets: [
{
name: 'SHA256SUMS.txt',
browser_download_url: 'https://example.test/SHA256SUMS.txt',
},
{
name: 'SubMiner.AppImage',
browser_download_url: 'https://example.test/SubMiner.AppImage',
},
],
},
],
text: async () => '',
arrayBuffer: async () => new ArrayBuffer(0),
};
}) as typeof fetch;
try {
const handled = await runUpdateCommand(makeContext(), {
runAppCommandCaptureOutput: () => {
throw new Error('unexpected Electron launch');
},
readMainConfig: () => null,
log: (level, _configured, message) => {
calls.push(`${level}:${message}`);
},
});
assert.equal(handled, true);
assert.deepEqual(calls, [
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
'info:AppImage update: up to date',
'info:Launcher update: up to date',
'info:Rofi theme update: up to date',
]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('runUpdateCommand keeps app-mediated update path on non-Linux', async () => {
const calls: string[] = [];
const handled = await runUpdateCommand(
makeContext({
processAdapter: {
platform: () => 'darwin',
} as LauncherCommandContext['processAdapter'],
appPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
}),
{
createTempDir: () => '/tmp/subminer-update-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandCaptureOutput: (appPath, appArgs) => {
calls.push(`app:${appPath}:${appArgs.join(' ')}`);
return { status: 0, stdout: '', stderr: '' };
},
waitForUpdateResponse: async () => ({ ok: true, status: 'up-to-date' }),
removeDir: (targetPath) => {
calls.push(`remove:${targetPath}`);
},
},
);
assert.equal(handled, true);
assert.deepEqual(calls, [
'app:/Applications/SubMiner.app/Contents/MacOS/SubMiner:--update --update-launcher-path /home/kyle/.local/bin/subminer --update-response-path /tmp/subminer-update-test/response.json',
'remove:/tmp/subminer-update-test',
]);
});
+133
View File
@@ -1,10 +1,27 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import packageJson from '../../package.json';
import { runAppCommandCaptureOutput } from '../mpv.js';
import { log as launcherLog } from '../log.js';
import { nowMs } from '../time.js';
import { sleep } from '../util.js';
import type { LauncherCommandContext } from './context.js';
import { readLauncherMainConfigObject } from '../config/shared-config-reader.js';
import type { UpdateChannel } from '../../src/types/config.js';
import { updateAppImageFromRelease } from '../../src/main/runtime/update/appimage-updater.js';
import { updateLauncherFromRelease } from '../../src/main/runtime/update/launcher-updater.js';
import {
compareSemverLike,
fetchLatestStableRelease,
fetchReleaseAssetBuffer,
fetchReleaseAssetText,
findReleaseAsset,
parseReleaseVersion,
parseSha256Sums,
type FetchLike,
} from '../../src/main/runtime/update/release-assets.js';
import { updateSupportAssetsFromRelease } from '../../src/main/runtime/update/support-assets.js';
type UpdateCommandResponse = {
ok: boolean;
@@ -13,6 +30,18 @@ type UpdateCommandResponse = {
error?: string;
};
type DirectReleaseUpdateRequest = {
appPath: string;
launcherPath: string;
channel: UpdateChannel;
};
type DirectReleaseUpdateResult = {
appImage: { status: string; command?: string; message?: string };
launcher: { status: string; command?: string; message?: string };
supportAssets: Array<{ status: string; command?: string; message?: string }>;
};
type UpdateCommandDeps = {
createTempDir: (prefix: string) => string;
joinPath: (...parts: string[]) => string;
@@ -22,9 +51,95 @@ type UpdateCommandDeps = {
) => { status: number; stdout: string; stderr: string; error?: Error };
waitForUpdateResponse: (responsePath: string) => Promise<UpdateCommandResponse>;
removeDir: (targetPath: string) => void;
runDirectReleaseUpdate: (
request: DirectReleaseUpdateRequest,
) => Promise<DirectReleaseUpdateResult>;
readMainConfig: () => Record<string, unknown> | null;
log: typeof launcherLog;
};
const UPDATE_RESPONSE_TIMEOUT_MS = 10 * 60 * 1000;
const CURRENT_VERSION = packageJson.version;
function getFetchForLauncherUpdater(): FetchLike {
return globalThis.fetch.bind(globalThis) as FetchLike;
}
async function runDirectReleaseUpdate(
request: DirectReleaseUpdateRequest,
): Promise<DirectReleaseUpdateResult> {
const fetchForUpdater = getFetchForLauncherUpdater();
const release = await fetchLatestStableRelease({
fetch: fetchForUpdater,
channel: request.channel,
});
const releaseVersion = parseReleaseVersion(release);
if (releaseVersion && compareSemverLike(releaseVersion, CURRENT_VERSION) <= 0) {
return {
appImage: { status: 'up-to-date' },
launcher: { status: 'up-to-date' },
supportAssets: [{ status: 'up-to-date' }],
};
}
const sumsAsset = release ? findReleaseAsset(release, 'SHA256SUMS.txt') : null;
const sha256Sums =
sumsAsset && release
? parseSha256Sums(
await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url),
)
: new Map<string, string>();
const downloadAsset = (url: string) => fetchReleaseAssetBuffer(fetchForUpdater, url);
const [appImage, launcher, supportAssets] = await Promise.all([
updateAppImageFromRelease({
release,
sha256Sums,
appImagePath: request.appPath,
downloadAsset,
}),
updateLauncherFromRelease({
release,
sha256Sums,
launcherPath: request.launcherPath,
downloadAsset,
}),
updateSupportAssetsFromRelease({
release,
sha256Sums,
downloadAsset,
}),
]);
return { appImage, launcher, supportAssets };
}
function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel {
const updates =
root?.updates && typeof root.updates === 'object' && !Array.isArray(root.updates)
? (root.updates as Record<string, unknown>)
: null;
return updates?.channel === 'prerelease' ? 'prerelease' : 'stable';
}
function logUpdateResult(
label: string,
result: { status: string; command?: string; message?: string },
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
deps: Pick<UpdateCommandDeps, 'log'>,
): void {
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
deps.log('info', configuredLogLevel, `${label} update: ${displayStatus}`);
if (result.command) {
deps.log(
'warn',
configuredLogLevel,
`${label} update requires manual command: ${result.command}`,
);
} else if (result.message) {
deps.log('warn', configuredLogLevel, `${label} update note: ${result.message}`);
}
}
const defaultDeps: UpdateCommandDeps = {
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
@@ -47,6 +162,9 @@ const defaultDeps: UpdateCommandDeps = {
removeDir: (targetPath) => {
fs.rmSync(targetPath, { recursive: true, force: true });
},
runDirectReleaseUpdate,
readMainConfig: readLauncherMainConfigObject,
log: launcherLog,
};
export async function runUpdateCommand(
@@ -59,6 +177,21 @@ export async function runUpdateCommand(
return false;
}
if (context.processAdapter.platform() === 'linux') {
const result = await resolvedDeps.runDirectReleaseUpdate({
appPath,
launcherPath: scriptPath,
channel: readUpdateChannel(resolvedDeps.readMainConfig()),
});
const logLevel = args.logLevel ?? 'warn';
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
for (const supportResult of result.supportAssets) {
logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps);
}
return true;
}
const tempDir = resolvedDeps.createTempDir('subminer-update-');
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
+5 -5
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.14.0",
"version": "0.15.0-beta.2",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -15,7 +15,7 @@
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
"build:yomitan": "bun scripts/build-yomitan.mjs",
"build:assets": "bun scripts/prepare-build-assets.mjs",
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=dist/launcher/subminer",
"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",
@@ -47,8 +47,8 @@
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
"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/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/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/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.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: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 +70,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/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/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",
-50
View File
@@ -1,50 +0,0 @@
## Highlights
### Added
- **Character Dictionary:** Added AniList-based selection to resolve character dictionary mismatches, with series-scoped overrides that replace stale entries. Available via `subminer dictionary --candidates` / `--select` and a default `Ctrl+Alt+A` in-app shortcut.
- **Subtitle Bar Toggle:** Added a `V` shortcut and mpv binding to toggle the primary subtitle bar independently of mpv's native subtitle display.
- **Texthooker:** Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser.
### Changed
- **mpv Plugin Setup:** Managed launches now inject the bundled plugin automatically. The setup flow can trash detected legacy global plugin files before launch, and legacy global install entrypoints have been removed so regular mpv playback is unaffected.
- **Tray Menu:** Replaced "Open Overlay" with "Open Help," which opens the session help modal.
- **Stats Exclusions:** Vocabulary exclusions now persist in the immersion database and migrate existing browser-local exclusions on first load.
- **Config Defaults:** Disabled texthooker startup, subtitle, and annotation websocket servers by default. Fresh installs now use a Japanese font stack, transparent subtitle backgrounds, stronger text shadows, and teal N4/fourth-band coloring for primary subtitles. Yomitan popup auto-pause remains enabled.
- **Config Example:** The generated example config now lists every built-in keybinding default.
### Fixed
- **Subtitle Annotations — Grammar Filtering:** Suppressed N+1, JLPT, frequency, and name styling on grammar-only tokens: standalone interjections (`あ`, katakana variants), kana grammar helpers (`ことに`), auxiliary inflection fragments (`れる`, `れた`), polite copula tails (`です`, `じゃないですか`), standalone particles matched by known-word decks, and existence verbs (`ある`/`有る`). Known-word highlighting is preserved where applicable.
- **Subtitle Annotations — Color Priority:** Fixed token color priority so typography settings are preserved, JLPT colors no longer override higher-priority known-word or frequency colors, JLPT underlines persist at their correct color after dictionary lookups and when a token also carries known-word or frequency annotations, and frequency highlighting works correctly for ordinal prefix-noun tokens like `第二`.
- **Subtitle Annotations — Other:** Stopped kana-only tokens from being selected as N+1 targets; preserved Yomitan compound tokens so known component words no longer color a larger unknown word green; kept annotation prefetch running after immediate cache-hit renders; added a brightness lift for annotated token hover states when hover backgrounds are transparent; accepted `subtitleStyle.hoverBackground` as an alias for `subtitleStyle.hoverTokenBackgroundColor`; refreshed the current subtitle after successful card mining so newly known words recolor immediately.
- **Subtitle Bar:** Changed `v` to cycle the primary subtitle bar through visible, hover, and hidden modes with OSD feedback. Added `subtitleStyle.primaryDefaultMode` to set the startup visibility default independently from secondary subtitles.
- **Tokenizer:** Now uses Yomitan `wordClasses` metadata for part-of-speech filtering, and backfills blank MeCab POS fields during parser enrichment.
- **Overlay (Linux):** Fixed multi-line subtitle copy timing out after the prompt; follow-up number-row digits are now accepted for multi-line mining even when the original shortcut modifiers are still held.
- **Overlay (Hyprland):** Fixed fullscreen transitions so overlay geometry refreshes on mpv fullscreen changes, topmost stacking is reasserted, and hover pause works correctly after resize/toggle cycles. Overlay windows now align precisely to mpv bounds with floating decoration disabled; the stats overlay is opaque to prevent mpv bleed-through at the top edge; overlay windows no longer pin across workspaces.
- **Overlay (macOS):** Kept the overlay visible and interactive during transient tracker refreshes while mpv is the active tracked window, and kept it behind unrelated foreground windows while remaining above mpv.
- **Overlay:** Keyboard-only Yomitan popup shortcuts now take precedence over overlay keybindings like `j`; the browser focus outline is hidden so focused overlays no longer show a yellow/orange viewport border.
- **Default Keybindings:** Fixed replay/next subtitle keybindings — session help moved to `Ctrl/Cmd+/`, freeing `Ctrl+Shift+H` and `Ctrl+Shift+L` for subtitle playback controls. `Ctrl+Shift+L` now correctly reaches play-next-subtitle, and play-next resumes from a paused state before pausing again at the subtitle end.
- **Anki:** Manual clipboard subtitle updates preserve existing word audio while replacing sentence audio, animated-image media, and expression fields — even when audio overwrite is configured off.
- **AniList:** Post-watch progress checks now run on time-position updates using the fresh mpv position; manual mark-watched forces a progress sync; missing episode metadata is filled from the filename parser. Duplicate writes during concurrent checks are prevented, and manual watched marks are preserved when sync fails.
- **AniList (Linux):** Retried safeStorage availability after transient keyring failures so tokens can load and save once the keyring becomes available. Prevented config reload from opening the setup window during playback when token storage cannot be resolved, and stopped the setup flow from reporting success when token persistence fails.
- **mpv:** Stopped mpv from holding SubMiner subprocesses during shutdown, preventing desktop crash notifications on video close. Kept the overlay alive across same-media buffering reloads to avoid duplicate startup gates and AniSkip lookups; playlist navigation now reuses the running overlay without repeating the pause-until-ready warmup gate.
- **Launcher:** Managed playback now exits the background SubMiner app when the video closes; explicit background launches remain persistent.
- **Stats:** Background mode routes through the isolated stats daemon; app startup defers to an already-running daemon instead of failing when the port is already in use. Fixed recent session detail pages showing "Media not found" before lifetime media summaries are available.
- **Jellyfin:** Improved setup with recent server selection and inline authentication feedback. Added a tray toggle for runtime-only cast discovery.
### Docs
- Improved the docs homepage with canonical URLs and a cleaner sitemap.
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+102 -19
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,6 +25,18 @@ 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? = {
guard CommandLine.arguments.count > 1 else {
return nil
@@ -141,11 +153,44 @@ 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 windowStateFromAccessibilityAPI() -> WindowState? {
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? {
let runningApps = NSWorkspace.shared.runningApplications.filter { app in
guard let name = app.localizedName else {
return false
@@ -153,7 +198,8 @@ private func windowStateFromAccessibilityAPI() -> WindowState? {
return normalizedMpvName(name)
}
let frontmostPid = frontmostApplicationPid()
let frontmost = frontmostApplicationState()
var foundMinimizedTargetWindow = false
for app in runningApps {
let appElement = AXUIElementCreateApplication(app.processIdentifier)
@@ -168,14 +214,12 @@ private func windowStateFromAccessibilityAPI() -> WindowState? {
}
for window in windows {
var minimizedRef: CFTypeRef?
let minimizedStatus = AXUIElementCopyAttributeValue(window, kAXMinimizedAttribute as CFString, &minimizedRef)
if minimizedStatus == .success, let minimized = minimizedRef as? Bool, minimized {
var windowPid: pid_t = 0
if AXUIElementGetPid(window, &windowPid) != .success {
continue
}
var windowPid: pid_t = 0
if AXUIElementGetPid(window, &windowPid) != .success {
if windowPid != app.processIdentifier {
continue
}
@@ -183,15 +227,28 @@ private func windowStateFromAccessibilityAPI() -> WindowState? {
continue
}
var minimizedRef: CFTypeRef?
let minimizedStatus = AXUIElementCopyAttributeValue(window, kAXMinimizedAttribute as CFString, &minimizedRef)
if minimizedStatus == .success, let minimized = minimizedRef as? Bool, minimized {
foundMinimizedTargetWindow = true
continue
}
if let geometry = geometryFromAXWindow(window) {
return WindowState(
geometry: geometry,
focused: frontmostPid == windowPid
return .visible(
WindowState(
geometry: geometry,
focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)
)
)
}
}
}
if foundMinimizedTargetWindow {
return .minimized
}
return nil
}
@@ -200,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,
@@ -243,17 +300,43 @@ private func windowStateFromCoreGraphics() -> WindowState? {
return WindowState(
geometry: geometry,
focused: frontmostPid == ownerPid
focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)
)
}
return nil
}
if let window = windowStateFromAccessibilityAPI() ?? windowStateFromCoreGraphics() {
print(
"\(window.geometry.x),\(window.geometry.y),\(window.geometry.width),\(window.geometry.height),\(window.focused ? 1 : 0)"
)
private let lookupResult: WindowLookupResult? = {
if let axResult = windowStateFromAccessibilityAPI() {
return axResult
}
if let cgWindow = windowStateFromCoreGraphics() {
return .visible(cgWindow)
}
let frontmost = frontmostApplicationState()
if isFrontmostTargetMpv(frontmost) {
return .active
}
if frontmost != nil {
return .inactive
}
return nil
}()
if let result = lookupResult {
switch result {
case .visible(let window):
print(
"\(window.geometry.x),\(window.geometry.y),\(window.geometry.width),\(window.geometry.height),\(window.focused ? 1 : 0)"
)
case .minimized:
print("minimized")
case .active:
print("active")
case .inactive:
print("inactive")
}
} else {
print("not-found")
}
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync('scripts/get-mpv-window-macos.swift', 'utf8');
test('minimized Accessibility windows are validated by PID and socket before reporting minimized', () => {
const minimizedAssignmentIndex = source.indexOf('foundMinimizedTargetWindow = true');
assert.notEqual(minimizedAssignmentIndex, -1);
const loopStartIndex = source.lastIndexOf('for window in windows', minimizedAssignmentIndex);
assert.notEqual(loopStartIndex, -1);
const pidExtractionIndex = source.indexOf(
'AXUIElementGetPid(window, &windowPid)',
loopStartIndex,
);
const appPidMatchIndex = source.indexOf('windowPid != app.processIdentifier', loopStartIndex);
const socketCheckIndex = source.indexOf('if !windowHasTargetSocket(windowPid)', loopStartIndex);
assert.ok(
pidExtractionIndex > loopStartIndex && pidExtractionIndex < minimizedAssignmentIndex,
'window PID must be extracted before accepting a minimized window',
);
assert.ok(
appPidMatchIndex > pidExtractionIndex && appPidMatchIndex < minimizedAssignmentIndex,
'window PID must match the owning app before accepting a minimized window',
);
assert.ok(
socketCheckIndex > appPidMatchIndex && socketCheckIndex < minimizedAssignmentIndex,
'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',
);
});
+17 -4
View File
@@ -130,8 +130,8 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`);
},
openFirstRunSetup: () => {
calls.push('openFirstRunSetup');
openFirstRunSetup: (force?: boolean) => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
},
setVisibleOverlayVisible: (visible) => {
calls.push(`setVisibleOverlayVisible:${visible}`);
@@ -247,6 +247,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
log: (message) => {
calls.push(`log:${message}`);
},
logDebug: (message) => {
calls.push(`debug:${message}`);
},
warn: (message) => {
calls.push(`warn:${message}`);
},
@@ -358,13 +361,23 @@ test('handleCliCommand processes --start for second-instance when overlay runtim
);
});
test('handleCliCommand forces setup open for second-instance setup command', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ setup: true }), 'second-instance', deps);
assert.ok(calls.includes('openFirstRunSetup:force'));
assert.ok(calls.includes('debug:Opened first-run setup flow.'));
});
test('handleCliCommand opens first-run setup window for --setup', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ setup: true }), 'initial', deps);
assert.ok(calls.includes('openFirstRunSetup'));
assert.ok(calls.includes('log:Opened first-run setup flow.'));
assert.ok(calls.includes('openFirstRunSetup:force'));
assert.ok(calls.includes('debug:Opened first-run setup flow.'));
assert.equal(calls.includes('log:Opened first-run setup flow.'), false);
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
});
+7 -4
View File
@@ -41,7 +41,7 @@ export interface CliCommandServiceDeps {
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void;
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettingsDelayed: (delayMs: number) => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
getMultiCopyTimeoutMs: () => number;
showMpvOsd: (text: string) => void;
log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
@@ -157,7 +158,7 @@ interface MiningCliRuntime {
}
interface UiCliRuntime {
openFirstRunSetup: () => void;
openFirstRunSetup: (force?: boolean) => void;
openYomitanSettings: () => void;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
@@ -211,6 +212,7 @@ export interface CliCommandDepsRuntimeOptions {
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => unknown;
log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
@@ -286,6 +288,7 @@ export function createCliCommandDepsRuntime(
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
showMpvOsd: options.mpv.showOsd,
log: options.log,
logDebug: options.logDebug,
warn: options.warn,
error: options.error,
};
@@ -378,8 +381,8 @@ export function handleCliCommand(
} else if (args.togglePrimarySubtitleBar) {
deps.togglePrimarySubtitleBar();
} else if (args.setup) {
deps.openFirstRunSetup();
deps.log('Opened first-run setup flow.');
deps.openFirstRunSetup(true);
deps.logDebug('Opened first-run setup flow.');
} else if (args.settings) {
deps.openYomitanSettingsDelayed(1000);
} else if (args.show || args.showVisibleOverlay) {
+32 -1
View File
@@ -218,6 +218,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
getMainWindow: () => null,
getVisibleOverlayVisibility: () => false,
onOverlayModalClosed: () => {},
onOverlayMouseInteractionChanged: (active) => {
calls.push(`overlay-interaction:${active}`);
},
openYomitanSettings: () => {},
quitApp: () => {},
toggleVisibleOverlay: () => {},
@@ -281,6 +284,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 +302,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[] = [];
+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);
},
);
@@ -547,7 +547,7 @@ test('initializeOverlayRuntime hides overlay windows when tracker loses the targ
assert.deepEqual(calls, ['hide-visible', 'hide-modal', 'sync-shortcuts']);
});
test('initializeOverlayRuntime hides visible overlay on Windows tracker loss when target is not minimized', () => {
test('initializeOverlayRuntime refreshes visible overlay on tracker loss when target is not minimized', () => {
const calls: string[] = [];
const tracker = {
onGeometryChange: null as ((...args: unknown[]) => void) | null,
@@ -600,7 +600,7 @@ test('initializeOverlayRuntime hides visible overlay on Windows tracker loss whe
calls.length = 0;
tracker.onWindowLost?.();
assert.deepEqual(calls, ['hide-visible', 'sync-shortcuts']);
assert.deepEqual(calls, ['update-visible']);
});
test('initializeOverlayRuntime restores overlay bounds and visibility when tracker finds the target window again', () => {
+7 -3
View File
@@ -105,10 +105,14 @@ export function initializeOverlayRuntime(options: {
};
windowTracker.onWindowLost = () => {
options.releaseOverlayOwner?.();
for (const window of options.getOverlayWindows()) {
window.hide();
if (windowTracker.isTargetWindowMinimized()) {
for (const window of options.getOverlayWindows()) {
window.hide();
}
options.syncOverlayShortcuts();
return;
}
options.syncOverlayShortcuts();
options.updateVisibleOverlayVisibility();
};
windowTracker.onWindowFocusChange = () => {
if (options.isVisibleOverlayVisible()) {
+445 -11
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'));
});
@@ -883,7 +934,7 @@ test('visible overlay stays hidden while a modal window is active', () => {
assert.ok(!calls.includes('update-bounds'));
});
test('macOS tracked visible overlay stays interactive without passively stealing focus', () => {
test('macOS tracked visible overlay starts click-through without passively stealing focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
@@ -915,12 +966,53 @@ test('macOS tracked visible overlay stays interactive without passively stealing
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('show'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
test('macOS keeps active mpv overlay visible and interactive during tracker refresh', () => {
test('macOS tracked visible overlay remains click-through even if the overlay had focus', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
setFocused(true);
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);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(!calls.includes('focus'));
});
test('macOS keeps active mpv overlay visible and click-through during tracker refresh', () => {
const { window, calls } = createMainWindowRecorder();
const osdMessages: string[] = [];
const tracker: WindowTrackerStub = {
@@ -961,7 +1053,7 @@ test('macOS keeps active mpv overlay visible and interactive during tracker refr
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
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'));
@@ -969,7 +1061,7 @@ test('macOS keeps active mpv overlay visible and interactive during tracker refr
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,
@@ -977,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,
@@ -1006,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[] = [];
@@ -1060,7 +1343,7 @@ test('macOS preserves an already visible active mpv overlay while tracker is tem
assert.equal(trackerWarning, false);
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('hide'));
@@ -1101,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'));
});
@@ -1390,7 +1674,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
assert.deepEqual(osdMessages, []);
assert.ok(calls.includes('update-bounds'));
assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:false:plain'));
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'));
@@ -1398,6 +1682,156 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
assert.ok(!calls.includes('show'));
});
test('macOS hides visible overlay during tracker loss after mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => false,
getGeometry: () => null,
isTargetWindowFocused: () => false,
isTargetWindowMinimized: () => 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,
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('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'));
});
test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => {
const { window } = createMainWindowRecorder();
const osdMessages: string[] = [];
+84 -16
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,16 +106,34 @@ 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 =
!args.isMacOSPlatform || !args.windowTracker
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
? true
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
: (trackedMacOSTargetFocused ?? true);
const shouldReleaseMacOSOverlayLevel =
args.isMacOSPlatform &&
!!args.windowTracker &&
!hasTransientMacOSTrackerLoss &&
!isVisibleOverlayFocused &&
!isTrackedMacOSTargetFocused;
// Renderer hover tracking temporarily disables this for subtitle and popup interaction.
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive;
const shouldDefaultToPassthrough =
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
const windowsForegroundProcessName =
@@ -127,6 +158,7 @@ export function updateVisibleOverlayVisibility(args: {
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
const shouldIgnoreMouseEvents =
shouldUseMacOSMousePassthrough ||
forceMousePassthrough ||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
@@ -143,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 =
@@ -163,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) {
setOverlayWindowOpacity(mainWindow, 0);
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
scheduleWindowsOverlayReveal(
mainWindow,
shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined,
);
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal(
mainWindow,
shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined,
);
}
} else {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0);
@@ -193,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();
}
@@ -200,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;
@@ -242,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();
@@ -274,9 +333,18 @@ 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 =
canReportMacOSTargetMinimized && args.windowTracker.isTargetWindowMinimized();
const shouldPreserveTransientTrackedOverlay =
(args.isMacOSPlatform &&
(hasRetainedTrackedGeometry || (mainWindow.isVisible() && hasActiveMacOSTargetSignal))) ||
!isTrackedMacOSTargetMinimized &&
(hasRetainedTrackedGeometry ||
(mainWindow.isVisible() && hasActiveMacOSOverlaySignal) ||
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
(args.isWindowsPlatform &&
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
!args.windowTracker.isTargetWindowMinimized());
@@ -292,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,
});
});
+86
View File
@@ -358,3 +358,89 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
assert.ok(calls.indexOf('init-overlay') !== -1);
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay'));
});
test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => {
const calls: string[] = [];
await runAppReadyRuntime({
ensureDefaultConfigBootstrap: () => {
calls.push('bootstrap');
},
loadSubtitlePosition: () => {
calls.push('load-subtitle-position');
},
resolveKeybindings: () => {
calls.push('resolve-keybindings');
},
createMpvClient: () => {
calls.push('create-mpv');
},
reloadConfig: () => {
calls.push('reload-config');
},
getResolvedConfig: () => ({
websocket: { enabled: false },
annotationWebsocket: { enabled: false },
texthooker: { launchAtStartup: false },
}),
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {
calls.push('set-log-level');
},
initRuntimeOptionsManager: () => {
calls.push('init-runtime-options');
},
setSecondarySubMode: () => {
calls.push('set-secondary-sub-mode');
},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 0,
defaultAnnotationWebsocketPort: 0,
defaultTexthookerPort: 0,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {
calls.push('log');
},
createMecabTokenizerAndCheck: async () => {},
createSubtitleTimingTracker: () => {
calls.push('subtitle-timing');
},
createImmersionTracker: () => {
calls.push('immersion');
},
startJellyfinRemoteSession: async () => {},
loadYomitanExtension: async () => {
calls.push('load-yomitan-direct');
},
ensureYomitanExtensionLoaded: async () => {
calls.push('load-yomitan-guarded');
},
handleFirstRunSetup: async () => {
calls.push('first-run');
},
prewarmSubtitleDictionaries: async () => {},
startBackgroundWarmups: () => {
calls.push('warmups');
},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {
calls.push('visible-overlay');
},
initializeOverlayRuntime: () => {
calls.push('init-overlay');
},
handleInitialArgs: () => {
calls.push('handle-initial-args');
},
shouldUseMinimalStartup: () => false,
shouldSkipHeavyStartup: () => false,
});
assert.equal(calls.includes('load-yomitan-direct'), false);
assert.equal(calls.includes('load-yomitan-guarded'), true);
});
+7 -12
View File
@@ -131,6 +131,7 @@ export interface AppReadyRuntimeDeps {
createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise<void>;
loadYomitanExtension: () => Promise<void>;
ensureYomitanExtensionLoaded?: () => Promise<void>;
handleFirstRunSetup: () => Promise<void>;
prewarmSubtitleDictionaries?: () => Promise<void>;
startBackgroundWarmups: () => void;
@@ -215,6 +216,8 @@ export function isAutoUpdateEnabledRuntime(
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
const now = deps.now ?? (() => Date.now());
const startupStartedAtMs = now();
const ensureYomitanExtensionReady =
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
deps.ensureDefaultConfigBootstrap();
if (deps.shouldRunHeadlessInitialCommand?.()) {
deps.reloadConfig();
@@ -224,7 +227,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
} else {
deps.createMpvClient();
deps.createSubtitleTimingTracker();
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
deps.initializeOverlayRuntime();
deps.handleInitialArgs();
}
@@ -237,18 +240,10 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
return;
}
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
return;
}
deps.logDebug?.('App-ready critical path started.');
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
@@ -319,12 +314,12 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.');
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
deps.setVisibleOverlayVisible(true);
deps.initializeOverlayRuntime();
} else {
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
await deps.loadYomitanExtension();
await ensureYomitanExtensionReady();
}
await deps.handleFirstRunSetup();
+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);
}
@@ -9,6 +9,7 @@ import {
ensureExtensionCopyAsync,
shouldCopyYomitanExtension,
} from './yomitan-extension-copy';
import { withSuppressedYomitanExtensionWarnings } from './yomitan-extension-loader';
function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -19,6 +20,66 @@ function writeFile(filePath: string, content: string): void {
fs.writeFileSync(filePath, content, 'utf-8');
}
test('suppresses Yomitan contextMenus extension load warnings only while loading', async () => {
const emitted: string[] = [];
const warningProcess = {
emitWarning: (warning: string | Error, options?: { type?: string }) => {
const message = warning instanceof Error ? warning.message : warning;
emitted.push(`${options?.type ?? ''}:${message}`);
},
} as Pick<NodeJS.Process, 'emitWarning'>;
await withSuppressedYomitanExtensionWarnings(async () => {
warningProcess.emitWarning(
"Warnings loading extension:\nPermission 'contextMenus' is unknown.",
{
type: 'ExtensionLoadWarning',
},
);
warningProcess.emitWarning('Other extension warning', { type: 'ExtensionLoadWarning' });
return null;
}, warningProcess);
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
type: 'ExtensionLoadWarning',
});
assert.deepEqual(emitted, [
'ExtensionLoadWarning:Other extension warning',
"ExtensionLoadWarning:Permission 'contextMenus' is unknown.",
]);
});
test('suppressed Yomitan warning wrapper is re-entrant safe', async () => {
const emitted: string[] = [];
const warningProcess = {
emitWarning: (warning: string | Error, options?: { type?: string }) => {
const message = warning instanceof Error ? warning.message : warning;
emitted.push(`${options?.type ?? ''}:${message}`);
},
} as Pick<NodeJS.Process, 'emitWarning'>;
const originalEmitWarning = warningProcess.emitWarning;
await withSuppressedYomitanExtensionWarnings(async () => {
await withSuppressedYomitanExtensionWarnings(async () => {
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
type: 'ExtensionLoadWarning',
});
warningProcess.emitWarning('Nested warning', { type: 'ExtensionLoadWarning' });
}, warningProcess);
warningProcess.emitWarning("Permission 'contextMenus' is unknown.", {
type: 'ExtensionLoadWarning',
});
warningProcess.emitWarning('Outer warning', { type: 'ExtensionLoadWarning' });
}, warningProcess);
assert.equal(warningProcess.emitWarning, originalEmitWarning);
assert.deepEqual(emitted, [
'ExtensionLoadWarning:Nested warning',
'ExtensionLoadWarning:Outer warning',
]);
});
test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
const tempRoot = makeTempDir('subminer-yomitan-copy-');
const sourceDir = path.join(tempRoot, 'source');
@@ -185,10 +246,7 @@ test('ensureExtensionCopyAsync shares an in-flight refresh for the same copied e
assert.equal(results[0].copied, true);
assert.equal(results[1].copied, true);
assert.equal(
fs.readFileSync(
path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'),
'utf8',
),
fs.readFileSync(path.join(targetDir, 'js', 'pages', 'settings', 'settings-main.js'), 'utf8'),
'new settings code',
);
} finally {
+101 -9
View File
@@ -29,6 +29,85 @@ export interface YomitanExtensionLoaderDeps {
setYomitanSession: (session: Session | null) => void;
}
type WarningProcess = Pick<NodeJS.Process, 'emitWarning'>;
const suppressedWarningState = new WeakMap<
WarningProcess,
{
count: number;
originalEmitWarning: WarningProcess['emitWarning'];
}
>();
function getWarningType(warning: string | Error, args: unknown[]): string | undefined {
if (typeof warning !== 'string') {
return warning.name;
}
const firstArg = args[0];
if (typeof firstArg === 'string') {
return firstArg;
}
if (firstArg && typeof firstArg === 'object' && 'type' in firstArg) {
const type = (firstArg as { type?: unknown }).type;
return typeof type === 'string' ? type : undefined;
}
return undefined;
}
function shouldSuppressYomitanExtensionWarning(warning: string | Error, args: unknown[]): boolean {
const message = warning instanceof Error ? warning.message : warning;
return (
getWarningType(warning, args) === 'ExtensionLoadWarning' &&
message.includes("Permission 'contextMenus' is unknown.")
);
}
export async function withSuppressedYomitanExtensionWarnings<T>(
run: () => Promise<T>,
warningProcess: WarningProcess = process,
): Promise<T> {
const existingState = suppressedWarningState.get(warningProcess);
if (existingState) {
existingState.count++;
try {
return await run();
} finally {
existingState.count--;
if (existingState.count === 0) {
warningProcess.emitWarning = existingState.originalEmitWarning;
suppressedWarningState.delete(warningProcess);
}
}
}
const originalEmitWarning = warningProcess.emitWarning;
const state = {
count: 1,
originalEmitWarning,
};
suppressedWarningState.set(warningProcess, state);
warningProcess.emitWarning = ((warning: string | Error, ...args: unknown[]) => {
if (shouldSuppressYomitanExtensionWarning(warning, args)) {
return;
}
return (originalEmitWarning as (...emitArgs: unknown[]) => void).call(
warningProcess,
warning,
...args,
);
}) as typeof process.emitWarning;
try {
return await run();
} finally {
state.count--;
if (state.count === 0) {
warningProcess.emitWarning = originalEmitWarning;
suppressedWarningState.delete(warningProcess);
}
}
}
export async function loadYomitanExtension(
deps: YomitanExtensionLoaderDeps,
): Promise<Extension | null> {
@@ -79,9 +158,20 @@ export async function loadYomitanExtension(
return null;
}
const extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
let extensionCopy: { copied: boolean; targetDir: string };
try {
extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
} catch (error) {
logger.error('Failed to copy Yomitan extension:', {
error,
extensionPath: extPath,
userDataPath: deps.userDataPath,
});
clearRuntimeState();
return null;
}
if (extensionCopy.copied) {
logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
logger.debug(`Copied yomitan extension to ${extensionCopy.targetDir}`);
}
extPath = extensionCopy.targetDir;
}
@@ -91,13 +181,15 @@ export async function loadYomitanExtension(
try {
const extensions = targetSession.extensions;
const extension = extensions
? await extensions.loadExtension(extPath, {
allowFileAccess: true,
})
: await targetSession.loadExtension(extPath, {
allowFileAccess: true,
});
const extension = await withSuppressedYomitanExtensionWarnings(() =>
extensions
? extensions.loadExtension(extPath, {
allowFileAccess: true,
})
: targetSession.loadExtension(extPath, {
allowFileAccess: true,
}),
);
deps.setYomitanExtension(extension);
return extension;
} catch (err) {
+78 -4
View File
@@ -2,27 +2,101 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildYomitanSettingsCloseButtonScript,
buildYomitanSettingsWindowMenuTemplate,
buildYomitanSettingsUrl,
configureYomitanSettingsWindowChrome,
destroyYomitanSettingsWindow,
installYomitanSettingsCloseButton,
showYomitanSettingsWindow,
shouldInstallYomitanSettingsCloseButton,
} from './yomitan-settings';
test('yomitan settings window removes default app menu quit action', () => {
test('yomitan settings window uses a close-only menu without app quit', () => {
const calls: string[] = [];
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);
} as never, (template) => {
calls.push(`menu-label:${template[0]?.label ?? ''}`);
const submenu = template[0]?.submenu;
assert.ok(Array.isArray(submenu));
const closeItem = submenu[0];
assert.equal(closeItem?.label, 'Close');
assert.notEqual(closeItem?.role, 'quit');
closeItem?.click?.({} as never, {} as never, {} as never);
return { id: 'settings-menu' } as never;
});
assert.deepEqual(calls, ['auto-hide:true', 'menu:null']);
assert.deepEqual(calls, ['auto-hide:false', 'menu-label:File', 'close', 'menu:custom']);
});
test('yomitan settings close menu skips destroyed windows', () => {
const calls: string[] = [];
const template = buildYomitanSettingsWindowMenuTemplate({
isDestroyed: () => true,
close: () => calls.push('close'),
} as never);
const submenu = template[0]?.submenu;
assert.ok(Array.isArray(submenu));
submenu[0]?.click?.({} as never, {} as never, {} as never);
assert.deepEqual(calls, []);
});
test('yomitan settings close button script installs an idempotent in-page close control', () => {
const script = buildYomitanSettingsCloseButtonScript();
assert.match(script, /subminer-yomitan-settings-close/);
assert.match(script, /aria-label', 'Close Yomitan settings'/);
assert.match(script, /window\.close\(\)/);
assert.match(script, /getElementById\(buttonId\)/);
});
test('yomitan settings close button only installs for Hyprland sessions', () => {
assert.equal(
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
true,
);
assert.equal(
shouldInstallYomitanSettingsCloseButton('linux', { HYPRLAND_INSTANCE_SIGNATURE: '' }),
false,
);
assert.equal(
shouldInstallYomitanSettingsCloseButton('darwin', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
false,
);
assert.equal(
shouldInstallYomitanSettingsCloseButton('win32', { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
false,
);
});
test('yomitan settings close button injection skips non-Hyprland windows', () => {
const calls: string[] = [];
installYomitanSettingsCloseButton(
{
isDestroyed: () => false,
webContents: {
executeJavaScript: () => {
calls.push('execute');
return Promise.resolve();
},
},
} as never,
{ platform: 'darwin', env: { HYPRLAND_INSTANCE_SIGNATURE: 'hypr' } },
);
assert.deepEqual(calls, []);
});
test('yomitan settings URL disables the embedded popup preview', () => {
assert.equal(
buildYomitanSettingsUrl('abc123'),
'chrome-extension://abc123/settings.html?popup-preview=false&subminer-settings-safe=true',
'chrome-extension://abc123/settings.html?popup-preview=false',
);
});
+120 -7
View File
@@ -1,8 +1,8 @@
import electron from 'electron';
import type { BrowserWindow, Extension, Session } from 'electron';
import type { BrowserWindow, Extension, Menu, MenuItemConstructorOptions, Session } from 'electron';
import { createLogger } from '../../logger';
const { BrowserWindow: ElectronBrowserWindow, session } = electron;
const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = electron;
const logger = createLogger('main:yomitan-settings');
export interface OpenYomitanSettingsWindowOptions {
@@ -13,15 +13,127 @@ export interface OpenYomitanSettingsWindowOptions {
onWindowClosed?: () => void;
}
export function configureYomitanSettingsWindowChrome(
settingsWindow: Pick<BrowserWindow, 'setAutoHideMenuBar' | 'setMenu'>,
type YomitanSettingsWindowMenuOwner = Pick<BrowserWindow, 'close' | 'isDestroyed'>;
type HyprlandSessionEnv = {
HYPRLAND_INSTANCE_SIGNATURE?: string;
};
export interface InstallYomitanSettingsCloseButtonOptions {
platform?: NodeJS.Platform;
env?: HyprlandSessionEnv;
}
export function shouldInstallYomitanSettingsCloseButton(
platform: NodeJS.Platform = process.platform,
env: HyprlandSessionEnv = process.env,
): boolean {
return platform === 'linux' && Boolean(env.HYPRLAND_INSTANCE_SIGNATURE);
}
export function buildYomitanSettingsWindowMenuTemplate(
settingsWindow: YomitanSettingsWindowMenuOwner,
): MenuItemConstructorOptions[] {
return [
{
label: 'File',
submenu: [
{
label: 'Close',
accelerator: process.platform === 'darwin' ? 'Command+W' : 'Ctrl+W',
click: () => {
if (!settingsWindow.isDestroyed()) {
settingsWindow.close();
}
},
},
],
},
];
}
export function buildYomitanSettingsCloseButtonScript(): string {
return `
(() => {
const buttonId = 'subminer-yomitan-settings-close';
const styleId = 'subminer-yomitan-settings-close-style';
if (document.getElementById(buttonId)) {
return;
}
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = \`
#\${buttonId} {
position: fixed;
top: 10px;
left: 10px;
z-index: 2147483647;
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 4px;
background: rgba(24, 24, 24, 0.92);
color: #f2f2f2;
font: 22px/1 system-ui, sans-serif;
cursor: pointer;
}
#\${buttonId}:hover {
background: rgba(54, 54, 54, 0.96);
border-color: rgba(255, 255, 255, 0.5);
}
#\${buttonId}:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: 2px;
}
\`;
document.head.appendChild(style);
}
const button = document.createElement('button');
button.id = buttonId;
button.type = 'button';
button.title = 'Close';
button.setAttribute('aria-label', 'Close Yomitan settings');
button.textContent = '\\u00d7';
button.addEventListener('click', () => {
window.close();
});
document.body.appendChild(button);
})();
`;
}
export function installYomitanSettingsCloseButton(
settingsWindow: Pick<BrowserWindow, 'isDestroyed' | 'webContents'>,
options: InstallYomitanSettingsCloseButtonOptions = {},
): void {
settingsWindow.setAutoHideMenuBar(true);
settingsWindow.setMenu(null);
if (settingsWindow.isDestroyed()) {
return;
}
if (!shouldInstallYomitanSettingsCloseButton(options.platform, options.env)) {
return;
}
settingsWindow.webContents
.executeJavaScript(buildYomitanSettingsCloseButtonScript())
.catch((error: Error) => {
logger.warn('Failed to install Yomitan settings close button:', error.message);
});
}
export function configureYomitanSettingsWindowChrome(
settingsWindow: Pick<BrowserWindow, 'close' | 'isDestroyed' | 'setAutoHideMenuBar' | 'setMenu'>,
buildMenu: (template: MenuItemConstructorOptions[]) => Menu = (template) =>
ElectronMenu.buildFromTemplate(template),
): void {
settingsWindow.setAutoHideMenuBar(false);
settingsWindow.setMenu(buildMenu(buildYomitanSettingsWindowMenuTemplate(settingsWindow)));
}
export function buildYomitanSettingsUrl(extensionId: string): string {
return `chrome-extension://${extensionId}/settings.html?popup-preview=false&subminer-settings-safe=true`;
return `chrome-extension://${extensionId}/settings.html?popup-preview=false`;
}
export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
@@ -108,6 +220,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
settingsWindow.webContents.on('did-finish-load', () => {
logger.info('Settings page loaded successfully');
installYomitanSettingsCloseButton(settingsWindow);
});
setTimeout(() => {
+92 -36
View File
@@ -82,6 +82,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
return {
shouldUseMinimalStartup: Boolean(
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
initialArgs?.update ||
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
),
@@ -90,6 +91,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
(shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.update ||
initialArgs.setup),
),
};
@@ -365,6 +367,7 @@ import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/
import {
createFirstRunSetupService,
getFirstRunSetupCompletionMessage,
isStandaloneFirstRunSetupCommand,
shouldAutoOpenFirstRunSetup,
} from './main/runtime/first-run-setup-service';
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
@@ -483,13 +486,13 @@ import { createOverlayModalInputState } from './main/runtime/overlay-modal-input
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import { openCharacterDictionaryModal as openCharacterDictionaryModalRuntime } from './main/runtime/character-dictionary-open';
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
import { writeSessionBindingsArtifact } from './main/runtime/session-bindings-artifact';
import { openOverlayHostedModal } from './main/runtime/overlay-hosted-modal-open';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import {
createFrequencyDictionaryRuntimeService,
@@ -508,22 +511,21 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { createElectronAppUpdater } from './main/runtime/update/app-updater';
import {
createElectronAppUpdater,
isNativeUpdaterSupported,
} from './main/runtime/update/app-updater';
import {
fetchLatestStableRelease,
fetchReleaseAssetBuffer,
fetchReleaseAssetText,
findReleaseAsset,
parseSha256Sums,
type GitHubRelease,
} from './main/runtime/update/release-assets';
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
import {
showNoUpdateDialog,
showRestartDialog,
showUpdateAvailableDialog,
showUpdateFailedDialog,
} from './main/runtime/update/update-dialogs';
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs';
import {
runUpdateCliCommand,
writeUpdateCliCommandResponse,
@@ -847,6 +849,9 @@ const appLogger = {
logInfo: (message: string) => {
logger.info(message);
},
logDebug: (message: string) => {
logger.debug(message);
},
logWarning: (message: string) => {
logger.warn(message);
},
@@ -1479,9 +1484,11 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
},
showMpvOsd: (text) => showMpvOsd(text),
openManualPicker: (payload) => {
sendToActiveOverlayWindow('subsync:open-manual', payload, {
restoreOnModalClose: 'subsync',
});
openOverlayHostedModalWithOsd(
(deps) => openSubsyncManualModalRuntime(deps, payload),
'Subsync overlay unavailable.',
'Failed to open subsync overlay.',
);
},
});
const immersionMediaRuntime = createImmersionMediaRuntime(
@@ -2062,6 +2069,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible,
getOverlayInteractionActive: () => visibleOverlayInteractionActive,
getWindowTracker: () => appState.windowTracker,
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
@@ -2105,23 +2113,24 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
})(),
);
const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false;
let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0;
let visibleOverlayInteractionActive = false;
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
function clearVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
clearTimeout(timeout);
}
windowsVisibleOverlayBlurRefreshTimeouts = [];
visibleOverlayBlurRefreshTimeouts = [];
}
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
@@ -2322,20 +2331,22 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
}
function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform !== 'win32') {
if (process.platform !== 'win32' && process.platform !== 'darwin') {
return;
}
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
clearWindowsVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
if (process.platform === 'win32') {
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
}
clearVisibleOverlayBlurRefreshTimeouts();
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => {
windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter(
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout,
);
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, delayMs);
windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
}
}
@@ -2900,6 +2911,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
},
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode,
shouldQuitWhenClosedCompleted: () =>
Boolean(appState.initialArgs && isStandaloneFirstRunSetupCommand(appState.initialArgs)),
quitApp: () => requestAppQuit(),
clearSetupWindow: () => {
appState.firstRunSetupWindow = null;
@@ -3034,6 +3047,7 @@ const {
resetAnilistMediaTracking,
getAnilistMediaGuessRuntimeState,
setAnilistMediaGuessRuntimeState,
recordAnilistMediaDuration,
resetAnilistMediaGuessState,
maybeProbeAnilistDuration,
ensureAnilistMediaGuess,
@@ -3137,6 +3151,13 @@ const {
);
},
},
recordMediaDurationMainDeps: {
getCurrentMediaKey: () => getCurrentAnilistMediaKey(),
getState: () => getAnilistMediaGuessRuntimeState(),
setState: (state) => {
setAnilistMediaGuessRuntimeState(state);
},
},
resetMediaGuessStateMainDeps: {
setMediaGuess: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
@@ -3731,6 +3752,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => configService.reloadConfigStrict(),
logInfo: (message) => appLogger.logInfo(message),
logDebug: (message) => appLogger.logDebug(message),
logWarning: (message) => appLogger.logWarning(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
startConfigHotReload: () => configHotReloadRuntime.start(),
@@ -3854,6 +3876,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
loadYomitanExtension: async () => {
await loadYomitanExtension();
},
ensureYomitanExtensionLoaded: async () => {
await ensureYomitanExtensionLoaded();
},
handleFirstRunSetup: async () => {
const snapshot = await firstRunSetupService.ensureSetupStateInitialized();
appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
@@ -3971,7 +3996,10 @@ const {
reportJellyfinRemoteStopped: () => {
void reportJellyfinRemoteStopped();
},
maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(),
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
recordAnilistMediaDuration: (durationSec) => {
recordAnilistMediaDuration(durationSec);
},
logSubtitleTimingError: (message, error) => logger.error(message, error),
broadcastToOverlayWindows: (channel, payload) => {
broadcastToOverlayWindows(channel, payload);
@@ -4611,12 +4639,12 @@ function getFetchForUpdater() {
return globalThis.fetch.bind(globalThis);
}
async function updateLauncherFromLatestRelease(
async function updateLauncherFromSelectedRelease(
launcherPath?: string,
channel: UpdateChannel = getResolvedConfig().updates.channel,
release: GitHubRelease | null = null,
) {
const fetchForUpdater = getFetchForUpdater();
const release = await fetchLatestStableRelease({ fetch: fetchForUpdater, channel });
if (!release) {
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
}
@@ -4640,9 +4668,9 @@ async function updateLauncherFromLatestRelease(
});
for (const result of supportResults) {
if (result.status === 'protected' && result.command) {
logger.warn(`Support assets update requires manual command: ${result.command}`);
logger.warn(`Rofi theme update requires manual command: ${result.command}`);
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
logger.warn(`Support assets update skipped: ${result.message ?? result.status}`);
logger.warn(`Rofi theme update skipped: ${result.message ?? result.status}`);
}
}
return launcherResult;
@@ -4655,6 +4683,19 @@ function getUpdateService() {
isPackaged: app.isPackaged,
log: (message) => logger.info(message),
getChannel: () => getResolvedConfig().updates.channel,
isNativeUpdaterSupported: () =>
isNativeUpdaterSupported({
platform: process.platform,
isPackaged: app.isPackaged,
execPath: process.execPath,
env: process.env,
log: (message) => logger.warn(message),
}),
});
const updateDialogPresenter = createUpdateDialogPresenter({
platform: process.platform,
focusApp: () => app.focus({ steal: true }),
showMessageBox: (options) => dialog.showMessageBox(options),
});
updateService = createUpdateService({
getConfig: () => getResolvedConfig().updates,
@@ -4665,16 +4706,16 @@ function getUpdateService() {
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
fetchLatestStableRelease: (channel) =>
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
updateLauncher: (launcherPath, channel) =>
updateLauncherFromLatestRelease(launcherPath, channel),
showNoUpdateDialog: (version) =>
showNoUpdateDialog((options) => dialog.showMessageBox(options), version),
updateLauncher: (launcherPath, channel, release) =>
updateLauncherFromSelectedRelease(launcherPath, channel, release),
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
showUpdateAvailableDialog: (version) =>
showUpdateAvailableDialog((options) => dialog.showMessageBox(options), version),
showUpdateFailedDialog: (message) =>
showUpdateFailedDialog((options) => dialog.showMessageBox(options), message),
updateDialogPresenter.showUpdateAvailableDialog(version),
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
showManualUpdateRequiredDialog: (version) =>
updateDialogPresenter.showManualUpdateRequiredDialog(version),
downloadAppUpdate: () => appUpdater.downloadUpdate(),
showRestartDialog: () => showRestartDialog((options) => dialog.showMessageBox(options)),
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
quitAndInstall: () => appUpdater.quitAndInstall(),
notifyUpdateAvailable: (version) =>
notifyUpdateAvailable(
@@ -5100,6 +5141,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
onOverlayModalOpened: (modal) => {
overlayModalRuntime.notifyOverlayModalOpened(modal);
},
onOverlayMouseInteractionChanged: (active, senderWindow) => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || senderWindow !== mainWindow) {
return;
}
if (visibleOverlayInteractionActive === active) {
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}
return;
}
visibleOverlayInteractionActive = active;
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
},
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => requestAppQuit(),
@@ -5307,7 +5362,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
initializeOverlayRuntime: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force),
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
@@ -5365,6 +5420,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
logInfo: (message: string) => logger.info(message),
logDebug: (message: string) => logger.debug(message),
logWarn: (message: string) => logger.warn(message),
logError: (message: string, err: unknown) => logger.error(message, err),
},
+2
View File
@@ -44,6 +44,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
ensureYomitanExtensionLoaded?: AppReadyRuntimeDeps['ensureYomitanExtensionLoaded'];
handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup'];
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
@@ -109,6 +110,7 @@ export function createAppReadyRuntimeDeps(
createImmersionTracker: params.createImmersionTracker,
startJellyfinRemoteSession: params.startJellyfinRemoteSession,
loadYomitanExtension: params.loadYomitanExtension,
ensureYomitanExtensionLoaded: params.ensureYomitanExtensionLoaded,
handleFirstRunSetup: params.handleFirstRunSetup,
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
startBackgroundWarmups: params.startBackgroundWarmups,
@@ -1459,7 +1459,7 @@ test('generateForCurrentMedia preserves duplicate surface forms across different
}
});
test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => {
test('getOrCreateCurrentSnapshot reuses cached media resolution without AniList requests', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
let searchQueryCount = 0;
@@ -1567,11 +1567,18 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data',
});
const first = await runtime.getOrCreateCurrentSnapshot();
assert.equal(searchQueryCount, 1);
assert.equal(characterQueryCount, 1);
fs.rmSync(path.join(userDataPath, 'character-dictionaries', 'anilist-resolution-cache.json'), {
force: true,
});
const second = await runtime.getOrCreateCurrentSnapshot();
assert.equal(first.fromCache, false);
assert.equal(second.fromCache, true);
assert.equal(searchQueryCount, 2);
assert.equal(searchQueryCount, 1);
assert.equal(characterQueryCount, 1);
assert.equal(
fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')),
+60
View File
@@ -15,7 +15,10 @@ import {
getMergedZipPath,
getSnapshotPath,
normalizeMergedMediaIds,
readCachedMediaResolution,
readCachedSnapshots,
readSnapshot,
writeCachedMediaResolution,
writeSnapshot,
} from './character-dictionary-runtime/cache';
import {
@@ -41,6 +44,7 @@ import type {
CharacterDictionaryManualSelectionResult,
CharacterDictionaryManualSelectionSnapshot,
CharacterDictionaryRuntimeDeps,
CharacterDictionarySnapshot,
CharacterDictionarySnapshotImage,
CharacterDictionarySnapshotProgress,
CharacterDictionarySnapshotProgressCallbacks,
@@ -204,6 +208,26 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
};
};
const findCachedSnapshotForSeriesKey = (
seriesKey: string,
): CharacterDictionarySnapshot | null => {
return (
readCachedSnapshots(outputDir).find((snapshot) => {
const snapshotSeriesKey = buildCharacterDictionarySeriesKey({
mediaPath: null,
mediaTitle: snapshot.mediaTitle,
guess: {
title: snapshot.mediaTitle,
season: null,
episode: null,
source: 'fallback',
},
});
return snapshotSeriesKey === seriesKey;
}) ?? null
);
};
const resolveCurrentMedia = async (
targetPath?: string,
beforeRequest?: () => Promise<void>,
@@ -228,7 +252,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
staleMediaIds: override.staleMediaIds,
};
}
const cachedResolution = readCachedMediaResolution(outputDir, seriesKey);
if (cachedResolution) {
const cachedSnapshot = readSnapshot(getSnapshotPath(outputDir, cachedResolution.mediaId));
if (cachedSnapshot) {
deps.logInfo?.(
`[dictionary] cached AniList match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
);
return {
id: cachedSnapshot.mediaId,
title: cachedSnapshot.mediaTitle,
};
}
}
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey);
if (cachedSnapshot) {
writeCachedMediaResolution(outputDir, {
seriesKey,
mediaId: cachedSnapshot.mediaId,
mediaTitle: cachedSnapshot.mediaTitle,
});
deps.logInfo?.(
`[dictionary] cached snapshot match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
);
return {
id: cachedSnapshot.mediaId,
title: cachedSnapshot.mediaTitle,
};
}
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
writeCachedMediaResolution(outputDir, {
seriesKey,
mediaId: resolved.id,
mediaTitle: resolved.title,
});
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
return resolved;
};
@@ -21,6 +21,102 @@ export function getMergedZipPath(outputDir: string): string {
return path.join(outputDir, 'merged.zip');
}
type MediaResolutionCacheEntry = {
seriesKey: string;
mediaId: number;
mediaTitle: string;
};
type MediaResolutionCacheFile = {
entries?: MediaResolutionCacheEntry[];
};
function getMediaResolutionCachePath(outputDir: string): string {
return path.join(outputDir, 'anilist-resolution-cache.json');
}
function normalizeMediaResolutionEntry(value: unknown): MediaResolutionCacheEntry | null {
if (!value || typeof value !== 'object') return null;
const raw = value as Partial<MediaResolutionCacheEntry>;
const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : '';
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
if (typeof raw.mediaId !== 'number' || !Number.isFinite(raw.mediaId)) return null;
const mediaId = Math.floor(raw.mediaId);
if (!seriesKey || mediaId <= 0 || !mediaTitle) return null;
return {
seriesKey,
mediaId,
mediaTitle,
};
}
function readMediaResolutionEntries(outputDir: string): MediaResolutionCacheEntry[] {
try {
const parsed = JSON.parse(
fs.readFileSync(getMediaResolutionCachePath(outputDir), 'utf8'),
) as MediaResolutionCacheFile;
if (!Array.isArray(parsed.entries)) return [];
const byKey = new Map<string, MediaResolutionCacheEntry>();
for (const value of parsed.entries) {
const normalized = normalizeMediaResolutionEntry(value);
if (normalized) byKey.set(normalized.seriesKey, normalized);
}
return [...byKey.values()];
} catch {
return [];
}
}
function writeMediaResolutionEntries(
outputDir: string,
entries: MediaResolutionCacheEntry[],
): void {
ensureDir(outputDir);
fs.writeFileSync(
getMediaResolutionCachePath(outputDir),
JSON.stringify({ entries }, null, 2),
'utf8',
);
}
export function readCachedMediaResolution(
outputDir: string,
seriesKey: string,
): MediaResolutionCacheEntry | null {
const normalizedKey = seriesKey.trim();
if (!normalizedKey) return null;
return (
readMediaResolutionEntries(outputDir).find((entry) => entry.seriesKey === normalizedKey) ?? null
);
}
export function writeCachedMediaResolution(
outputDir: string,
entry: MediaResolutionCacheEntry,
): void {
const normalized = normalizeMediaResolutionEntry(entry);
if (!normalized) return;
const remaining = readMediaResolutionEntries(outputDir).filter(
(existing) => existing.seriesKey !== normalized.seriesKey,
);
writeMediaResolutionEntries(outputDir, [...remaining, normalized]);
}
export function readCachedSnapshots(outputDir: string): CharacterDictionarySnapshot[] {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((entry) => entry.isFile() && /^anilist-\d+\.json$/.test(entry.name))
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) => readSnapshot(path.join(getSnapshotsDir(outputDir), entry.name)))
.filter((snapshot): snapshot is CharacterDictionarySnapshot => snapshot !== null);
}
export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot | null {
try {
const raw = fs.readFileSync(snapshotPath, 'utf8');
+3 -1
View File
@@ -20,7 +20,7 @@ export interface CliCommandRuntimeServiceContext {
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void;
openFirstRunSetup: (force?: boolean) => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -54,6 +54,7 @@ export interface CliCommandRuntimeServiceContext {
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void;
error: (message: string, err: unknown) => void;
}
@@ -133,6 +134,7 @@ function createCliCommandDepsFromContext(
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
schedule: context.schedule,
log: context.log,
logDebug: context.logDebug,
warn: context.warn,
error: context.error,
};
+4
View File
@@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp'];
@@ -198,6 +199,7 @@ export interface CliCommandRuntimeServiceDepsParams {
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
schedule: CliCommandDepsRuntimeOptions['schedule'];
log: CliCommandDepsRuntimeOptions['log'];
logDebug: CliCommandDepsRuntimeOptions['logDebug'];
warn: CliCommandDepsRuntimeOptions['warn'];
error: CliCommandDepsRuntimeOptions['error'];
}
@@ -228,6 +230,7 @@ export function createMainIpcRuntimeServiceDeps(
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
onOverlayModalClosed: params.onOverlayModalClosed,
onOverlayModalOpened: params.onOverlayModalOpened,
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
onYoutubePickerResolve: params.onYoutubePickerResolve,
openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp,
@@ -377,6 +380,7 @@ export function createCliCommandRuntimeServiceDeps(
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
schedule: params.schedule,
log: params.log,
logDebug: params.logDebug,
warn: params.warn,
error: params.error,
};
+2
View File
@@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps {
getModalActive: () => boolean;
getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean;
getOverlayInteractionActive?: () => boolean;
getWindowTracker: () => BaseWindowTracker | null;
getLastKnownWindowsForegroundProcessName?: () => string | null;
getWindowsOverlayProcessName?: () => string | null;
@@ -49,6 +50,7 @@ export function createOverlayVisibilityRuntimeService(
visibleOverlayVisible,
modalActive: deps.getModalActive(),
forceMousePassthrough,
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
mainWindow,
windowTracker,
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
createBuildRecordAnilistMediaDurationMainDepsHandler,
createBuildResetAnilistMediaGuessStateMainDepsHandler,
createBuildResetAnilistMediaTrackingMainDepsHandler,
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
@@ -70,3 +71,32 @@ test('reset anilist media guess state main deps builder maps callbacks', () => {
deps.setMediaGuessPromise(null);
assert.deepEqual(calls, ['guess', 'promise']);
});
test('record anilist media duration main deps builder maps callbacks', () => {
const calls: string[] = [];
const state = {
mediaKey: '/tmp/video.mkv',
mediaDurationSec: null,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
};
const deps = createBuildRecordAnilistMediaDurationMainDepsHandler({
getCurrentMediaKey: () => {
calls.push('key');
return '/tmp/video.mkv';
},
getState: () => {
calls.push('get');
return state;
},
setState: () => {
calls.push('set');
},
})();
assert.equal(deps.getCurrentMediaKey(), '/tmp/video.mkv');
deps.getState();
deps.setState(state);
assert.deepEqual(calls, ['key', 'get', 'set']);
});
@@ -1,6 +1,7 @@
import type {
createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler,
createRecordAnilistMediaDurationHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler,
@@ -18,6 +19,9 @@ type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
typeof createSetAnilistMediaGuessRuntimeStateHandler
>[0];
type RecordAnilistMediaDurationMainDeps = Parameters<
typeof createRecordAnilistMediaDurationHandler
>[0];
type ResetAnilistMediaGuessStateMainDeps = Parameters<
typeof createResetAnilistMediaGuessStateHandler
>[0];
@@ -66,6 +70,16 @@ export function createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler(
});
}
export function createBuildRecordAnilistMediaDurationMainDepsHandler(
deps: RecordAnilistMediaDurationMainDeps,
) {
return (): RecordAnilistMediaDurationMainDeps => ({
getCurrentMediaKey: () => deps.getCurrentMediaKey(),
getState: () => deps.getState(),
setState: (state) => deps.setState(state),
});
}
export function createBuildResetAnilistMediaGuessStateMainDepsHandler(
deps: ResetAnilistMediaGuessStateMainDeps,
) {
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler,
createRecordAnilistMediaDurationHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler,
@@ -176,3 +177,57 @@ test('reset anilist media guess state clears guess and in-flight promise', () =>
assert.equal(state.mediaDurationSec, 240);
assert.equal(state.lastDurationProbeAtMs, 321);
});
test('record anilist media duration stores observed mpv duration for current media', () => {
const existingPromise = Promise.resolve(null);
let state = {
mediaKey: '/tmp/video.mkv' as string | null,
mediaDurationSec: null as number | null,
mediaGuess: { title: 'guess' } as { title: string } | null,
mediaGuessPromise: existingPromise as Promise<unknown> | null,
lastDurationProbeAtMs: 321,
};
const recordDuration = createRecordAnilistMediaDurationHandler({
getCurrentMediaKey: () => '/tmp/video.mkv',
getState: () => state as never,
setState: (nextState) => {
state = nextState as never;
},
});
recordDuration(1440);
assert.equal(state.mediaDurationSec, 1440);
assert.deepEqual(state.mediaGuess, { title: 'guess' });
assert.equal(state.mediaGuessPromise, existingPromise);
assert.equal(state.lastDurationProbeAtMs, 321);
});
test('record anilist media duration resets stale media state when media key changes', () => {
let state = {
mediaKey: '/tmp/old.mkv' as string | null,
mediaDurationSec: 120 as number | null,
mediaGuess: { title: 'old' } as { title: string } | null,
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
lastDurationProbeAtMs: 321,
};
const recordDuration = createRecordAnilistMediaDurationHandler({
getCurrentMediaKey: () => '/tmp/new.mkv',
getState: () => state as never,
setState: (nextState) => {
state = nextState as never;
},
});
recordDuration(1440);
assert.deepEqual(state, {
mediaKey: '/tmp/new.mkv',
mediaDurationSec: 1440,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
});
});
+31
View File
@@ -61,6 +61,37 @@ export function createSetAnilistMediaGuessRuntimeStateHandler(deps: {
};
}
export function createRecordAnilistMediaDurationHandler(deps: {
getCurrentMediaKey: () => string | null;
getState: () => AnilistMediaGuessRuntimeState;
setState: (state: AnilistMediaGuessRuntimeState) => void;
}) {
return (durationSec: number): void => {
if (!Number.isFinite(durationSec) || durationSec <= 0) {
return;
}
const mediaKey = deps.getCurrentMediaKey();
if (!mediaKey) {
return;
}
const state = deps.getState();
if (state.mediaKey === mediaKey) {
deps.setState({
...state,
mediaDurationSec: durationSec,
});
return;
}
deps.setState({
mediaKey,
mediaDurationSec: durationSec,
mediaGuess: null,
mediaGuessPromise: null,
lastDurationProbeAtMs: 0,
});
};
}
export function createResetAnilistMediaGuessStateHandler(deps: {
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
@@ -121,6 +121,46 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda
assert.ok(calls.includes('osd:updated ok'));
});
test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds from time-position events', async () => {
const calls: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 0,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 8 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => {
calls.push('update');
return { status: 'updated', message: 'updated ok' };
},
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler({ watchedSeconds: 850 });
assert.ok(calls.includes('update'));
assert.ok(calls.includes('remember'));
assert.ok(calls.includes('osd:updated ok'));
});
test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
const calls: string[] = [];
let inFlight = false;
+5 -1
View File
@@ -18,6 +18,7 @@ type RetryQueueItem = {
type AnilistPostWatchRunOptions = {
force?: boolean;
watchedSeconds?: number;
};
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
@@ -146,7 +147,10 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
let watchedSeconds = 0;
if (!force) {
watchedSeconds = deps.getWatchedSeconds();
watchedSeconds =
typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds)
? options.watchedSeconds
: deps.getWatchedSeconds();
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
return;
}
@@ -36,6 +36,9 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
loadYomitanExtension: async () => {
calls.push('load-yomitan');
},
ensureYomitanExtensionLoaded: async () => {
calls.push('ensure-yomitan');
},
handleFirstRunSetup: async () => {
calls.push('handle-first-run-setup');
},
@@ -67,6 +70,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
onReady.createMpvClient();
await onReady.createMecabTokenizerAndCheck();
await onReady.loadYomitanExtension();
await onReady.ensureYomitanExtensionLoaded?.();
await onReady.handleFirstRunSetup();
await onReady.prewarmSubtitleDictionaries?.();
onReady.startBackgroundWarmups();
@@ -79,6 +83,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
'create-mpv-client',
'create-mecab',
'load-yomitan',
'ensure-yomitan',
'handle-first-run-setup',
'prewarm-dicts',
'start-warmups',
+1
View File
@@ -27,6 +27,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
createImmersionTracker: deps.createImmersionTracker,
startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
loadYomitanExtension: deps.loadYomitanExtension,
ensureYomitanExtensionLoaded: deps.ensureYomitanExtensionLoaded,
handleFirstRunSetup: deps.handleFirstRunSetup,
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
startBackgroundWarmups: deps.startBackgroundWarmups,
@@ -81,6 +81,7 @@ test('build cli command context deps maps handlers and values', () => {
return setTimeout(() => {}, 0);
},
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
});
+3 -1
View File
@@ -18,7 +18,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void;
openFirstRunSetup: (force?: boolean) => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -52,6 +52,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
}) {
@@ -106,6 +107,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
schedule: deps.schedule,
logInfo: deps.logInfo,
logDebug: deps.logDebug,
logWarn: deps.logWarn,
logError: deps.logError,
});
@@ -82,6 +82,7 @@ test('cli command context factory composes main deps and context handlers', () =
getMultiCopyTimeoutMs: () => 5000,
schedule: (fn) => setTimeout(fn, 0),
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
logError: () => {},
});
@@ -30,7 +30,8 @@ test('cli command context main deps builder maps state and callbacks', async ()
initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'),
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
openFirstRunSetupWindow: () => calls.push('open-setup'),
openFirstRunSetupWindow: (force?: boolean) =>
calls.push(`open-setup:${force === true ? 'force' : 'default'}`),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'),
@@ -110,6 +111,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
return setTimeout(() => {}, 0);
},
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`),
});
@@ -125,11 +127,19 @@ test('cli command context main deps builder maps state and callbacks', async ()
assert.equal(deps.shouldOpenBrowser(), true);
deps.showOsd('hello');
deps.initializeOverlay();
deps.openFirstRunSetup();
deps.openFirstRunSetup(true);
deps.setVisibleOverlay(true);
deps.printHelp();
await deps.runUpdateCommand({ update: true } as never, 'initial');
assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'open-setup', 'set-visible:true', 'help']);
assert.deepEqual(calls, [
'osd:hello',
'init-overlay',
'open-setup:force',
'set-visible:true',
'help',
'run-update',
]);
const retry = await deps.retryAnilistQueueNow();
assert.deepEqual(retry, { ok: true, message: 'ok' });
@@ -28,7 +28,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetupWindow: () => void;
openFirstRunSetupWindow: (force?: boolean) => void;
setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
@@ -65,6 +65,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
}) {
@@ -97,7 +98,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
openFirstRunSetup: (force?: boolean) => deps.openFirstRunSetupWindow(force),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
@@ -134,6 +135,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(),
schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs),
logInfo: (message: string) => deps.logInfo(message),
logDebug: (message: string) => deps.logDebug(message),
logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, err: unknown) => deps.logError(message, err),
});
+5 -1
View File
@@ -66,6 +66,9 @@ function createDeps() {
logInfo: (message: string) => {
logs.push(`i:${message}`);
},
logDebug: (message: string) => {
logs.push(`d:${message}`);
},
logWarn: (message: string) => {
logs.push(`w:${message}`);
},
@@ -102,7 +105,8 @@ test('cli command context log methods map to deps loggers', () => {
const { deps, getLogs } = createDeps();
const context = createCliCommandContext(deps);
context.log('info');
context.logDebug('debug');
context.warn('warn');
context.error('error', new Error('x'));
assert.deepEqual(getLogs(), ['i:info', 'w:warn', 'e:error']);
assert.deepEqual(getLogs(), ['i:info', 'd:debug', 'w:warn', 'e:error']);
});
+3 -1
View File
@@ -23,7 +23,7 @@ export type CliCommandContextFactoryDeps = {
initializeOverlay: () => void;
toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void;
openFirstRunSetup: () => void;
openFirstRunSetup: (force?: boolean) => void;
setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void;
@@ -57,6 +57,7 @@ export type CliCommandContextFactoryDeps = {
getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void;
};
@@ -133,6 +134,7 @@ export function createCliCommandContext(
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
schedule: deps.schedule,
log: deps.logInfo,
logDebug: deps.logDebug,
warn: deps.logWarn,
error: deps.logError,
};
@@ -110,6 +110,21 @@ test('resolveBunInstallCommand uses Homebrew on macOS when available', () => {
);
});
test('detectBun reports homebrew install method from POSIX brew path', async () => {
const snapshot = await detectBun({
platform: 'darwin',
env: { PATH: '/opt/homebrew/bin:/usr/bin' },
existsSync: (candidate) => candidate === '/opt/homebrew/bin/brew',
accessSync: (candidate) => {
if (candidate !== '/opt/homebrew/bin/brew') throw new Error('not executable');
},
runCommand: async () => ({ exitCode: 127, stdout: '', stderr: 'missing' }),
});
assert.equal(snapshot.status, 'missing');
assert.equal(snapshot.installMethod, 'homebrew');
});
test('resolveLauncherInstallTarget prefers writable user bin on Linux', async () => {
const target = await resolveLauncherInstallTarget({
platform: 'linux',
@@ -144,6 +159,53 @@ test('resolveLauncherInstallTarget returns not_installable without writable PATH
assert.equal(target.installPath, null);
});
test('resolveLauncherInstallTarget skips Homebrew bin for empty macOS manual installs', async () => {
const target = await resolveLauncherInstallTarget({
platform: 'darwin',
homeDir: '/Users/tester',
env: { PATH: '/opt/homebrew/bin:/usr/local/bin:/Users/tester/.local/bin:/usr/bin' },
existsSync: (candidate) =>
candidate === '/opt/homebrew/bin' ||
candidate === '/usr/local/bin' ||
candidate === '/Users/tester/.local/bin' ||
candidate === '/usr/bin',
accessSync: (candidate) => {
if (
candidate !== '/opt/homebrew/bin' &&
candidate !== '/usr/local/bin' &&
candidate !== '/Users/tester/.local/bin'
) {
throw new Error('not writable');
}
},
});
assert.equal(target.status, 'not_installed');
assert.equal(target.pathDir, '/Users/tester/.local/bin');
assert.equal(target.installPath, '/Users/tester/.local/bin/subminer');
});
test('resolveLauncherInstallTarget uses usr local bin for macOS manual install when user bin is absent', async () => {
const target = await resolveLauncherInstallTarget({
platform: 'darwin',
homeDir: '/Users/tester',
env: { PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin' },
existsSync: (candidate) =>
candidate === '/opt/homebrew/bin' ||
candidate === '/usr/local/bin' ||
candidate === '/usr/bin',
accessSync: (candidate) => {
if (candidate !== '/opt/homebrew/bin' && candidate !== '/usr/local/bin') {
throw new Error('not writable');
}
},
});
assert.equal(target.status, 'not_installed');
assert.equal(target.pathDir, '/usr/local/bin');
assert.equal(target.installPath, '/usr/local/bin/subminer');
});
test('installLauncher writes Windows cmd shim and appends user PATH once', async () => {
const files = new Map<string, string>();
const dirs = new Set<string>();
@@ -209,6 +271,54 @@ test('detectLauncher reports shadowed when another subminer appears earlier on P
assert.equal(snapshot.installPath, '/home/tester/.local/bin/subminer');
});
test('detectLauncher accepts installed macOS launcher from user local bin before Homebrew target', async () => {
const snapshot = await detectLauncher({
platform: 'darwin',
homeDir: '/Users/tester',
env: { PATH: '/Users/tester/.local/bin:/opt/homebrew/bin:/usr/bin' },
existsSync: (candidate) =>
candidate === '/Users/tester/.local/bin' ||
candidate === '/opt/homebrew/bin' ||
candidate === '/Users/tester/.local/bin/subminer',
accessSync: () => undefined,
runCommand: async (command, args) => {
assert.equal(command, '/Users/tester/.local/bin/subminer');
assert.deepEqual(args, ['--help']);
return { exitCode: 0, stdout: 'help', stderr: '' };
},
bunSnapshot: createBunSnapshot('ready'),
});
assert.equal(snapshot.status, 'ready');
assert.equal(snapshot.commandPath, '/Users/tester/.local/bin/subminer');
assert.equal(snapshot.installPath, '/Users/tester/.local/bin/subminer');
assert.equal(snapshot.pathDir, '/Users/tester/.local/bin');
assert.equal(snapshot.shadowedBy, null);
});
test('detectLauncher accepts installed macOS launcher from Homebrew bin', async () => {
const snapshot = await detectLauncher({
platform: 'darwin',
homeDir: '/Users/tester',
env: { PATH: '/opt/homebrew/bin:/usr/bin' },
existsSync: (candidate) =>
candidate === '/opt/homebrew/bin' || candidate === '/opt/homebrew/bin/subminer',
accessSync: () => undefined,
runCommand: async (command, args) => {
assert.equal(command, '/opt/homebrew/bin/subminer');
assert.deepEqual(args, ['--help']);
return { exitCode: 0, stdout: 'help', stderr: '' };
},
bunSnapshot: createBunSnapshot('ready'),
});
assert.equal(snapshot.status, 'ready');
assert.equal(snapshot.commandPath, '/opt/homebrew/bin/subminer');
assert.equal(snapshot.installPath, '/opt/homebrew/bin/subminer');
assert.equal(snapshot.pathDir, '/opt/homebrew/bin');
assert.equal(snapshot.shadowedBy, null);
});
test('detectLauncher reports installed_bun_missing when launcher exists but bun is missing', async () => {
const snapshot = await detectLauncher({
platform: 'linux',
+58 -15
View File
@@ -72,21 +72,23 @@ const BUN_OFFICIAL_WINDOWS_COMMAND = [
];
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
const COMMAND_TIMEOUT_MS = 15 * 1000;
const MACOS_HOMEBREW_PATH_DIRS = ['/opt/homebrew/bin'];
function installMethodForCommand(
command: string[] | null,
): BunSnapshot['installMethod'] {
function installMethodForCommand(command: string[] | null): BunSnapshot['installMethod'] {
if (!command) return null;
const executablePath = command[0];
if (!executablePath) return null;
const executable = path.win32.basename(executablePath).toLowerCase();
if (executable === 'winget.exe') return 'winget';
if (executable === 'scoop.cmd') return 'scoop';
if (executable === 'brew') return 'homebrew';
const executable = path.basename(executablePath).toLowerCase();
const windowsExecutable = path.win32.basename(executablePath).toLowerCase();
if (windowsExecutable === 'winget.exe') return 'winget';
if (windowsExecutable === 'scoop.cmd') return 'scoop';
if (executable === 'brew' || windowsExecutable === 'brew') return 'homebrew';
return 'official-script';
}
export function resolveBunInstallCommand(options: CommonOptions = {}): BunSnapshot['installCommand'] {
export function resolveBunInstallCommand(
options: CommonOptions = {},
): BunSnapshot['installCommand'] {
const platform = platformOf(options);
if (platform === 'win32') {
const winget = findCommand('winget.exe', options);
@@ -154,7 +156,8 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
function resolveLauncherResourcePath(options: CommonOptions): string {
const platformPath = pathModuleFor(platformOf(options));
if (options.launcherResourcePath) return options.launcherResourcePath;
const resourcesPath = options.resourcesPath ?? (process as typeof process & { resourcesPath?: string }).resourcesPath;
const resourcesPath =
options.resourcesPath ?? (process as typeof process & { resourcesPath?: string }).resourcesPath;
const packaged = resourcesPath ? platformPath.join(resourcesPath, 'launcher', 'subminer') : null;
if (packaged && existsSyncOf(options)(packaged)) return packaged;
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer');
@@ -206,11 +209,47 @@ export async function resolveLauncherInstallTarget(
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
]
: [path.posix.join(homeDir, '.local', 'bin'), path.posix.join(homeDir, 'bin'), '/usr/local/bin'];
const candidates = [...preferred, ...pathDirs].filter((dir, index, all) =>
all.findIndex((other) => normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform)) === index,
: [
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
'/usr/local/bin',
];
const manualPreferred =
platform === 'darwin'
? [
path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'),
'/usr/local/bin',
]
: preferred;
const installCandidates = [...manualPreferred, ...pathDirs].filter(
(dir, index, all) =>
all.findIndex(
(other) =>
normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform),
) === index,
);
const installedPreferred = pathDirs.find((dir) => {
if (!pathEntriesContain(preferred, dir, platform)) return false;
return existsSyncOf(options)(path.posix.join(dir, 'subminer'));
});
if (installedPreferred) {
const installPath = path.posix.join(installedPreferred, 'subminer');
return {
status: 'ready',
commandPath: installPath,
installPath,
pathDir: installedPreferred,
shadowedBy: null,
message: null,
};
}
const selected = installCandidates.find(
(dir) =>
(platform !== 'darwin' || !pathEntriesContain(MACOS_HOMEBREW_PATH_DIRS, dir, platform)) &&
pathEntriesContain(pathDirs, dir, platform) &&
isWritableDir(dir, options),
);
const selected = candidates.find((dir) => pathEntriesContain(pathDirs, dir, platform) && isWritableDir(dir, options));
if (!selected) {
return {
status: 'not_installable',
@@ -258,10 +297,14 @@ export async function detectLauncher(
const commandPath = findCommand('subminer', options);
const expectedNormalized = normalizePathForCompare(expectedPath, platform, platformPath);
if (commandPath && normalizePathForCompare(commandPath, platform, platformPath) !== expectedNormalized) {
if (
commandPath &&
normalizePathForCompare(commandPath, platform, platformPath) !== expectedNormalized
) {
return { ...target, status: 'shadowed', commandPath: expectedPath, shadowedBy: commandPath };
}
if (!existsSyncOf(options)(expectedPath)) return { ...target, status: 'not_installed', commandPath: null };
if (!existsSyncOf(options)(expectedPath))
return { ...target, status: 'not_installed', commandPath: null };
if (!commandPath) {
return {
...target,
@@ -80,6 +80,23 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
lastDurationProbeAtMsState = value;
},
},
recordMediaDurationMainDeps: {
getCurrentMediaKey: () => 'media-key',
getState: () => ({
mediaKey: mediaKeyState,
mediaDurationSec: mediaDurationSecState,
mediaGuess: mediaGuessState,
mediaGuessPromise: mediaGuessPromiseState,
lastDurationProbeAtMs: lastDurationProbeAtMsState,
}),
setState: (state) => {
mediaKeyState = state.mediaKey;
mediaDurationSecState = state.mediaDurationSec;
mediaGuessState = state.mediaGuess;
mediaGuessPromiseState = state.mediaGuessPromise;
lastDurationProbeAtMsState = state.lastDurationProbeAtMs;
},
},
resetMediaGuessStateMainDeps: {
setMediaGuess: (value) => {
mediaGuessState = value;
@@ -192,6 +209,7 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
assert.equal(typeof composed.resetAnilistMediaTracking, 'function');
assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function');
assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function');
assert.equal(typeof composed.recordAnilistMediaDuration, 'function');
assert.equal(typeof composed.resetAnilistMediaGuessState, 'function');
assert.equal(typeof composed.maybeProbeAnilistDuration, 'function');
assert.equal(typeof composed.ensureAnilistMediaGuess, 'function');
@@ -216,6 +234,9 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
});
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90);
composed.recordAnilistMediaDuration(180);
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 180);
composed.resetAnilistMediaGuessState();
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null);
@@ -5,6 +5,7 @@ import {
createBuildMaybeProbeAnilistDurationMainDepsHandler,
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
createBuildRecordAnilistMediaDurationMainDepsHandler,
createBuildRefreshAnilistClientSecretStateMainDepsHandler,
createBuildResetAnilistMediaGuessStateMainDepsHandler,
createBuildResetAnilistMediaTrackingMainDepsHandler,
@@ -15,6 +16,7 @@ import {
createMaybeProbeAnilistDurationHandler,
createMaybeRunAnilistPostWatchUpdateHandler,
createProcessNextAnilistRetryUpdateHandler,
createRecordAnilistMediaDurationHandler,
createRefreshAnilistClientSecretStateHandler,
createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler,
@@ -38,6 +40,9 @@ export type AnilistTrackingComposerOptions = ComposerInputs<{
setMediaGuessRuntimeStateMainDeps: Parameters<
typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler
>[0];
recordMediaDurationMainDeps: Parameters<
typeof createBuildRecordAnilistMediaDurationMainDepsHandler
>[0];
resetMediaGuessStateMainDeps: Parameters<
typeof createBuildResetAnilistMediaGuessStateMainDepsHandler
>[0];
@@ -63,6 +68,7 @@ export type AnilistTrackingComposerResult = ComposerOutputs<{
setAnilistMediaGuessRuntimeState: ReturnType<
typeof createSetAnilistMediaGuessRuntimeStateHandler
>;
recordAnilistMediaDuration: ReturnType<typeof createRecordAnilistMediaDurationHandler>;
resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>;
maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>;
ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>;
@@ -94,6 +100,9 @@ export function composeAnilistTrackingHandlers(
options.setMediaGuessRuntimeStateMainDeps,
)(),
);
const recordAnilistMediaDuration = createRecordAnilistMediaDurationHandler(
createBuildRecordAnilistMediaDurationMainDepsHandler(options.recordMediaDurationMainDeps)(),
);
const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler(
createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(),
);
@@ -120,6 +129,7 @@ export function composeAnilistTrackingHandlers(
resetAnilistMediaTracking,
getAnilistMediaGuessRuntimeState,
setAnilistMediaGuessRuntimeState,
recordAnilistMediaDuration,
resetAnilistMediaGuessState,
maybeProbeAnilistDuration,
ensureAnilistMediaGuess,
@@ -11,6 +11,7 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
return { ok: true, path: '/tmp/config.jsonc', warnings: [] };
},
logInfo: () => {},
logDebug: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
startConfigHotReload: () => {},
@@ -58,6 +58,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
getMultiCopyTimeoutMs: () => 0,
schedule: () => 0 as never,
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
logError: () => {},
},
@@ -82,6 +82,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinPreviewAuth: false,
texthooker: false,
texthookerOpenBrowser: false,
update: false,
help: false,
autoStartOverlay: false,
generateConfig: false,
@@ -124,6 +125,7 @@ test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
false,
);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
});
test('shouldAutoOpenFirstRunSetup treats numeric startup counts as explicit commands', () => {
@@ -119,6 +119,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.jellyfinRemoteAnnounce ||
args.jellyfinPreviewAuth ||
args.texthooker ||
args.update ||
args.help,
);
}
@@ -129,6 +130,10 @@ export function shouldAutoOpenFirstRunSetup(args: CliArgs): boolean {
return !hasAnyStartupCommandBeyondSetup(args);
}
export function isStandaloneFirstRunSetupCommand(args: CliArgs): boolean {
return args.setup && !args.start && !hasAnyStartupCommandBeyondSetup(args);
}
function getPluginStatus(
state: SetupState,
pluginInstalled: boolean,
+251 -2
View File
@@ -65,6 +65,9 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Finish setup/);
assert.match(html, /disabled/);
assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/);
assert.match(html, /min-height:\s*100vh;/);
assert.match(html, /box-sizing:\s*border-box;/);
});
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
@@ -305,19 +308,60 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions',
assert.match(html, /Installed, Bun missing/);
assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/);
assert.match(html, /action=install-command-line-launcher/);
assert.match(html, /<button class="primary" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/);
assert.match(
html,
/<button class="primary" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/,
);
});
test('buildFirstRunSetupHtml disables launcher install when no target is installable', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot({
launcher: {
status: 'not_installable',
commandPath: null,
installPath: null,
pathDir: null,
shadowedBy: null,
message: 'No writable PATH directory found.',
},
}),
message: null,
});
assert.match(
html,
/<button disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=install-command-line-launcher'">Install launcher<\/button>/,
);
});
test('first-run setup window handler focuses existing window', () => {
const calls: string[] = [];
const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => ({
show: () => calls.push('show'),
focus: () => calls.push('focus'),
}),
});
assert.equal(maybeFocus(), true);
assert.deepEqual(calls, ['focus']);
assert.deepEqual(calls, ['show', 'focus']);
});
test('first-run setup navigation handler prevents default and dispatches supported action', async () => {
@@ -366,6 +410,138 @@ test('first-run setup navigation handler swallows stale custom-scheme actions',
assert.deepEqual(calls, ['preventDefault']);
});
test('opening first-run setup shows and focuses window after content loads', async () => {
const calls: string[] = [];
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: () => {},
},
loadURL: async () => {
calls.push('load');
},
on: () => {},
isDestroyed: () => false,
close: () => {},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
}) as never,
getSetupSnapshot: async () => ({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
}),
buildSetupHtml: () => '<html></html>',
parseSubmissionUrl: () => null,
handleAction: async () => undefined,
markSetupInProgress: async () => {
calls.push('in-progress');
},
markSetupCancelled: async () => undefined,
isSetupCompleted: () => true,
shouldQuitWhenClosedIncomplete: () => false,
quitApp: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'load', 'show', 'focus']);
});
test('opening first-run setup skips rendering if window is destroyed after snapshot', async () => {
const calls: string[] = [];
let destroyed = false;
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: () => {},
},
loadURL: async () => {
calls.push('load');
},
on: () => {},
isDestroyed: () => destroyed,
close: () => {},
show: () => calls.push('show'),
focus: () => calls.push('focus'),
}) as never,
getSetupSnapshot: async () => {
calls.push('snapshot');
destroyed = true;
return {
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
};
},
buildSetupHtml: () => {
calls.push('build');
return '<html></html>';
},
parseSubmissionUrl: () => null,
handleAction: async () => undefined,
markSetupInProgress: async () => {
calls.push('in-progress');
},
markSetupCancelled: async () => undefined,
isSetupCompleted: () => true,
shouldQuitWhenClosedIncomplete: () => false,
quitApp: () => {},
clearSetupWindow: () => {},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'snapshot']);
});
test('closing incomplete first-run setup quits app outside background mode', async () => {
const calls: string[] = [];
let closedHandler: (() => void) | undefined;
@@ -437,3 +613,76 @@ test('closing incomplete first-run setup quits app outside background mode', asy
assert.deepEqual(calls, ['set', 'cancelled', 'clear', 'quit']);
});
test('closing completed first-run setup quits app when completion policy allows it', async () => {
const calls: string[] = [];
let closedHandler: (() => void) | undefined;
const handler = createOpenFirstRunSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () =>
({
webContents: {
on: () => {},
},
loadURL: async () => undefined,
on: (event: 'closed', callback: () => void) => {
if (event === 'closed') {
closedHandler = callback;
}
},
isDestroyed: () => false,
close: () => calls.push('close-window'),
focus: () => {},
}) as never,
getSetupSnapshot: async () => ({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
commandLineLauncher: createCommandLineLauncherSnapshot(),
message: null,
}),
buildSetupHtml: () => '<html></html>',
parseSubmissionUrl: () => null,
handleAction: async () => undefined,
markSetupInProgress: async () => undefined,
markSetupCancelled: async () => {
calls.push('cancelled');
},
isSetupCompleted: () => true,
shouldQuitWhenClosedIncomplete: () => true,
shouldQuitWhenClosedCompleted: () => true,
quitApp: () => {
calls.push('quit');
},
clearSetupWindow: () => {
calls.push('clear');
},
setSetupWindow: () => {
calls.push('set');
},
encodeURIComponent: (value) => value,
logError: () => {},
});
handler();
if (typeof closedHandler !== 'function') {
throw new Error('expected closed handler');
}
closedHandler();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['set', 'clear', 'quit']);
});
+30 -3
View File
@@ -7,6 +7,7 @@ import type {
type FocusableWindowLike = {
focus: () => void;
show?: () => void;
};
type FirstRunSetupWebContentsLike = {
@@ -124,7 +125,9 @@ function getLauncherTone(
return 'muted';
}
function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLauncherSnapshot): string {
function renderCommandLineLauncherSection(
commandLineLauncher: CommandLineLauncherSnapshot,
): string {
if (!commandLineLauncher.supported) {
return '';
}
@@ -154,7 +157,7 @@ function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLaunch
bun.status === 'missing' || bun.status === 'failed'
? `<button onclick="window.location.href='subminer://first-run-setup?action=install-bun'">Install Bun</button>`
: '';
const launcherButtonDisabled = launcher.status === 'failed' ? '' : '';
const launcherButtonDisabled = launcher.status === 'not_installable' ? 'disabled' : '';
return `
<section class="setup-section">
@@ -345,13 +348,20 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
--yellow: #eed49f;
--red: #ed8796;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
min-height: 100vh;
background: linear-gradient(180deg, var(--mantle), var(--base));
color: var(--text);
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
box-sizing: border-box;
min-height: 100vh;
padding: 18px;
}
h1 {
@@ -583,6 +593,7 @@ export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
return (): boolean => {
const window = deps.getSetupWindow();
if (!window) return false;
window.show?.();
window.focus();
return true;
};
@@ -626,6 +637,7 @@ export function createOpenFirstRunSetupWindowHandler<
markSetupCancelled: () => Promise<unknown>;
isSetupCompleted: () => boolean;
shouldQuitWhenClosedIncomplete: () => boolean;
shouldQuitWhenClosedCompleted?: () => boolean;
quitApp: () => void;
clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void;
@@ -639,11 +651,23 @@ export function createOpenFirstRunSetupWindowHandler<
const setupWindow = deps.createSetupWindow();
deps.setSetupWindow(setupWindow);
setupWindow.show?.();
setupWindow.focus();
const render = async (): Promise<void> => {
const model = await deps.getSetupSnapshot();
if (setupWindow.isDestroyed()) {
return;
}
const html = deps.buildSetupHtml(model);
if (setupWindow.isDestroyed()) {
return;
}
await setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(html)}`);
if (!setupWindow.isDestroyed()) {
setupWindow.show?.();
setupWindow.focus();
}
};
const handleNavigation = createHandleFirstRunSetupNavigationHandler({
@@ -682,7 +706,10 @@ export function createOpenFirstRunSetupWindowHandler<
});
}
deps.clearSetupWindow();
if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) {
if (
(setupCompleted && deps.shouldQuitWhenClosedCompleted?.()) ||
(!setupCompleted && deps.shouldQuitWhenClosedIncomplete())
) {
deps.quitApp();
}
});
@@ -223,6 +223,23 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
]);
});
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
const watchedSeconds: unknown[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: () => {},
reportJellyfinRemoteProgress: () => {},
refreshDiscordPresence: () => {},
maybeRunAnilistPostWatchUpdate: async (options) => {
watchedSeconds.push(options?.watchedSeconds);
},
});
timeHandler({ time: 850 });
await Promise.resolve();
assert.deepEqual(watchedSeconds, [850]);
});
test('time-pos handler logs post-watch update rejection without blocking later handlers', async () => {
const calls: string[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({
+6 -2
View File
@@ -1,5 +1,9 @@
import type { SubtitleData } from '../../types';
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
export function createHandleMpvSubtitleChangeHandler(deps: {
setCurrentSubText: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
@@ -105,7 +109,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
recordPlaybackPosition: (time: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
refreshDiscordPresence: () => void;
maybeRunAnilistPostWatchUpdate?: () => Promise<void>;
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
logError?: (message: string, error: unknown) => void;
onTimePosUpdate?: (time: number) => void;
}) {
@@ -113,7 +117,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
deps.recordPlaybackPosition(time);
deps.reportJellyfinRemoteProgress(false);
deps.refreshDiscordPresence();
void deps.maybeRunAnilistPostWatchUpdate?.().catch((error) => {
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
deps.logError?.('AniList post-watch update failed unexpectedly', error);
});
deps.onTimePosUpdate?.(time);
+6 -2
View File
@@ -18,6 +18,10 @@ import {
type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandlers>>[0];
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
@@ -34,7 +38,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
hasSubtitleTimingTracker: () => boolean;
recordSubtitleTiming: (text: string, start: number, end: number) => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
logSubtitleTimingError: (message: string, error: unknown) => void;
setCurrentSubText: (text: string) => void;
@@ -149,7 +153,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteProgress: (forceImmediate) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
logError: (message, error) => deps.logSubtitleTimingError(message, error),
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
});
@@ -16,6 +16,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`),
recordMediaDuration: (durationSec: number) => calls.push(`immersion-duration:${durationSec}`),
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
},
subtitleTimingTracker: {
@@ -40,6 +41,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('anilist-post-watch');
},
recordAnilistMediaDuration: (durationSec) => calls.push(`anilist-duration:${durationSec}`),
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
@@ -95,6 +97,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate('title');
deps.recordPlaybackPosition(10);
deps.recordMediaDuration(1234);
deps.reportJellyfinRemoteProgress(true);
deps.onFullscreenChange?.(true);
deps.recordPauseState(true);
@@ -118,6 +121,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-sidebar-layout'));
assert.ok(calls.includes('immersion-duration:1234'));
assert.ok(calls.includes('anilist-duration:1234'));
});
test('mpv main event main deps wire subtitle callbacks without suppression gate', () => {
+9 -2
View File
@@ -1,5 +1,9 @@
import type { MergedToken, SubtitleData } from '../../types';
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
appState: {
initialArgs?: {
@@ -42,7 +46,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
quitApp: () => void;
reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void;
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
recordAnilistMediaDuration?: (durationSec: number) => void;
logSubtitleTimingError: (message: string, error: unknown) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
@@ -126,7 +131,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) =>
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
deps.maybeRunAnilistPostWatchUpdate(options),
logSubtitleTimingError: (message: string, error: unknown) =>
deps.logSubtitleTimingError(message, error),
setCurrentSubText: (text: string) => {
@@ -179,6 +185,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordMediaDuration: (durationSec: number) => {
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordMediaDuration?.(durationSec);
deps.recordAnilistMediaDuration?.(durationSec);
},
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
@@ -15,6 +15,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
getModalActive: () => true,
getVisibleOverlayVisible: () => true,
getForceMousePassthrough: () => true,
getOverlayInteractionActive: () => true,
getWindowTracker: () => tracker,
getLastKnownWindowsForegroundProcessName: () => 'mpv',
getWindowsOverlayProcessName: () => 'subminer',
@@ -40,6 +41,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
assert.equal(deps.getModalActive(), true);
assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getForceMousePassthrough(), true);
assert.equal(deps.getOverlayInteractionActive?.(), true);
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
@@ -10,6 +10,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
getModalActive: () => deps.getModalActive(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false,
getWindowTracker: () => deps.getWindowTracker(),
getLastKnownWindowsForegroundProcessName: () =>
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
@@ -17,8 +17,8 @@ test('createCreateFirstRunSetupWindowHandler builds first-run setup window', ()
assert.deepEqual(createSetupWindow(), { id: 'first-run' });
assert.deepEqual(options, {
width: 560,
height: 640,
width: 720,
height: 860,
title: 'SubMiner Setup',
show: true,
autoHideMenuBar: true,
+2 -2
View File
@@ -32,8 +32,8 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return createSetupWindowHandler(deps, {
width: 560,
height: 640,
width: 720,
height: 860,
title: 'SubMiner Setup',
resizable: false,
minimizable: false,
@@ -10,6 +10,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
const deps = createBuildReloadConfigMainDepsHandler({
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('start-hot-reload'),
@@ -30,6 +31,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
warnings: [],
});
deps.logInfo('x');
deps.logDebug('debug');
deps.logWarning('y');
deps.showDesktopNotification('SubMiner', { body: 'warn' });
deps.startConfigHotReload();
@@ -39,6 +41,7 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
deps.failHandlers.quit();
assert.deepEqual(calls, [
'info:x',
'debug:debug',
'warn:y',
'notify:SubMiner:warn',
'start-hot-reload',
@@ -7,6 +7,7 @@ export function createBuildReloadConfigMainDepsHandler(deps: ReloadConfigMainDep
return (): ReloadConfigMainDeps => ({
reloadConfigStrict: () => deps.reloadConfigStrict(),
logInfo: (message: string) => deps.logInfo(message),
logDebug: (message: string) => deps.logDebug(message),
logWarning: (message: string) => deps.logWarning(message),
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
+8 -1
View File
@@ -20,6 +20,7 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
],
}),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('hotReload:start'),
@@ -36,7 +37,11 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
reloadConfig();
await Promise.resolve();
assert.ok(calls.some((entry) => entry.startsWith('info:Using config file: /tmp/config.jsonc')));
assert.ok(calls.some((entry) => entry.startsWith('debug:Using config file: /tmp/config.jsonc')));
assert.equal(
calls.some((entry) => entry.startsWith('info:Using config file: /tmp/config.jsonc')),
false,
);
assert.ok(calls.some((entry) => entry.startsWith('warn:[config] Validation found 1 issue(s)')));
assert.ok(
calls.some((entry) => entry.includes('notify:SubMiner:1 config validation issue(s) detected.')),
@@ -64,6 +69,7 @@ test('createReloadConfigHandler fails startup for parse errors', () => {
error: 'unexpected token',
}),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('hotReload:start'),
@@ -102,6 +108,7 @@ test('createReloadConfigHandler can skip AniList refresh for headless commands',
warnings: [],
}),
logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('hotReload:start'),
+2 -1
View File
@@ -24,6 +24,7 @@ type ReloadConfigStrictResult = ReloadConfigFailure | ReloadConfigSuccess;
export type ReloadConfigRuntimeDeps = {
reloadConfigStrict: () => ReloadConfigStrictResult;
logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarning: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
startConfigHotReload: () => void;
@@ -61,7 +62,7 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () =>
);
}
deps.logInfo(`Using config file: ${result.path}`);
deps.logDebug(`Using config file: ${result.path}`);
if (result.warnings.length > 0) {
deps.logWarning(buildConfigWarningSummary(result.path, result.warnings));
deps.showDesktopNotification('SubMiner', {
+110
View File
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openSubsyncManualModal } from './subsync-open';
import type { SubsyncManualPayload } from '../../types';
const payload: SubsyncManualPayload = {
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
};
test('subsync manual open prefers dedicated modal window on first attempt', async () => {
const sends: Array<{
channel: string;
payload: SubsyncManualPayload;
options: {
restoreOnModalClose: 'subsync';
preferModalWindow: boolean;
};
}> = [];
const opened = await openSubsyncManualModal(
{
ensureOverlayStartupPrereqs: () => {},
ensureOverlayWindowsReadyForVisibilityActions: () => {},
sendToActiveOverlayWindow: (channel, nextPayload, options) => {
sends.push({
channel,
payload: nextPayload as SubsyncManualPayload,
options: options as {
restoreOnModalClose: 'subsync';
preferModalWindow: boolean;
},
});
return true;
},
waitForModalOpen: async (modal, timeoutMs) => {
assert.equal(modal, 'subsync');
assert.equal(timeoutMs, 1500);
return true;
},
logWarn: () => {
throw new Error('should not warn on first-attempt success');
},
},
payload,
);
assert.equal(opened, true);
assert.deepEqual(sends, [
{
channel: 'subsync:open-manual',
payload,
options: {
restoreOnModalClose: 'subsync',
preferModalWindow: true,
},
},
]);
});
test('subsync manual open retries on the dedicated modal window after open timeout', async () => {
const preferModalWindowValues: boolean[] = [];
const warnings: string[] = [];
let waitCalls = 0;
const opened = await openSubsyncManualModal(
{
ensureOverlayStartupPrereqs: () => {},
ensureOverlayWindowsReadyForVisibilityActions: () => {},
sendToActiveOverlayWindow: (_channel, _payload, options) => {
preferModalWindowValues.push(Boolean(options?.preferModalWindow));
return true;
},
waitForModalOpen: async () => {
waitCalls += 1;
return waitCalls === 2;
},
logWarn: (message) => {
warnings.push(message);
},
},
payload,
);
assert.equal(opened, true);
assert.deepEqual(preferModalWindowValues, [true, true]);
assert.deepEqual(warnings, [
'Subsync modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
]);
});
test('subsync manual open fails when the dedicated modal window cannot be targeted', async () => {
let waitCalls = 0;
const opened = await openSubsyncManualModal(
{
ensureOverlayStartupPrereqs: () => {},
ensureOverlayWindowsReadyForVisibilityActions: () => {},
sendToActiveOverlayWindow: () => false,
waitForModalOpen: async () => {
waitCalls += 1;
return true;
},
logWarn: () => {},
},
payload,
);
assert.equal(opened, false);
assert.equal(waitCalls, 0);
});

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