Compare commits

..

3 Commits

Author SHA1 Message Date
sudacode 44609f3da0 fix(macos): validate PID and socket before reporting window as minimized
- Move PID extraction and app PID match before minimized check in windowStateFromAccessibilityAPI
- Add test asserting correct validation order in get-mpv-window-macos.swift
- Include new test in test:fast suite
2026-05-15 18:58:42 -07:00
sudacode b9ca198039 fix(macos): default overlay to click-through before subtitle hover
- Set shouldUseMacOSMousePassthrough so overlay starts in forward passthrough mode
- Renderer hover tracking re-enables interaction only over subtitle/popup areas
- Update tests to expect mouse-ignore:true:forward instead of mouse-ignore:false:plain
- Add test covering click-through behavior when overlay already had focus
2026-05-15 18:36:19 -07:00
sudacode 00811922fc fix(macos): preserve overlay on transient tracker loss and fix subsync m
- macOS tracker now reports minimized vs not-found so transient helper misses no longer hide the overlay; minimizing mpv still triggers hide
- overlay-runtime-init skips hide on non-minimized window-lost and calls updateVisibleOverlayVisibility instead
- overlay-visibility preserves window level and passthrough state during transient tracker loss
- subsync modal open uses dedicated modal window with retry logic to fix first-attempt flash and stale modal state on macOS
2026-05-15 18:36:07 -07:00
113 changed files with 280 additions and 4100 deletions
+2 -13
View File
@@ -47,13 +47,6 @@ jobs:
- name: Build (TypeScript check) - name: Build (TypeScript check)
run: bun run typecheck 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) - name: Test suite (source)
run: bun run test:fast run: bun run test:fast
@@ -369,12 +362,8 @@ jobs:
id: version id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Verify committed prerelease notes - name: Generate prerelease notes from pending fragments
run: | run: bun run changelog:prerelease-notes --version "${{ steps.version.outputs.VERSION }}"
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 - name: Publish Prerelease
env: env:
-1
View File
@@ -10,7 +10,6 @@ dist/
release/* release/*
!release/ !release/
!release/release-notes.md !release/release-notes.md
!release/prerelease-notes.md
build/yomitan/ build/yomitan/
coverage/ coverage/
+3 -3
View File
@@ -20,9 +20,9 @@ MACOS_APP_DIR ?= $(HOME)/Applications
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
# If building from source, the AppImage will typically land in release/. # If building from source, the AppImage will typically land in release/.
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage)) APPIMAGE_SRC := $(firstword $(wildcard release/SubMiner-*.AppImage))
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app)) MACOS_APP_SRC := $(firstword $(wildcard release/*.app release/*/*.app))
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip)) MACOS_ZIP_SRC := $(firstword $(wildcard release/SubMiner-*.zip))
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown) UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
ifeq ($(OS),Windows_NT) ifeq ($(OS),Windows_NT)
+3 -4
View File
@@ -217,13 +217,12 @@ Download the latest DMG or ZIP from [GitHub Releases](https://github.com/ksyasud
Also download the `subminer` launcher (recommended): Also download the `subminer` launcher (recommended):
```bash ```bash
mkdir -p ~/.local/bin sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer \
curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o ~/.local/bin/subminer \ && sudo chmod +x /usr/local/bin/subminer
&& chmod +x ~/.local/bin/subminer
``` ```
> [!NOTE] > [!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. Make sure `~/.local/bin` is on your PATH before installing there. > 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.
</details> </details>
+1 -1
View File
@@ -1,4 +1,4 @@
type: added type: added
area: updater area: updater
- 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. - 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.
@@ -1,4 +0,0 @@
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
@@ -1,4 +0,0 @@
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
@@ -1,10 +0,0 @@
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.
-5
View File
@@ -1,5 +0,0 @@
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
@@ -1,4 +0,0 @@
type: fixed
area: launcher
- Fixed Linux first-run launcher installs by building the packaged launcher with a valid Bun shebang.
-4
View File
@@ -1,4 +0,0 @@
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
@@ -1,4 +0,0 @@
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
@@ -1,4 +0,0 @@
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
@@ -1,4 +0,0 @@
type: fixed
area: updater
- Bring macOS update dialogs to the front when `subminer --update` is run from the launcher.
-4
View File
@@ -1,4 +0,0 @@
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
@@ -1,4 +0,0 @@
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
@@ -1,4 +0,0 @@
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
@@ -1,4 +0,0 @@
type: fixed
area: setup
- Quit standalone setup app launches after first-run setup finishes, returning the terminal instead of leaving the app process open.
+3 -2
View File
@@ -3,8 +3,9 @@ area: tray
- Kept the tray app running when closing tray-launched Yomitan settings. - Kept the tray app running when closing tray-launched Yomitan settings.
- Kept tray-launched Yomitan settings loading from blocking other tray actions. - Kept tray-launched Yomitan settings loading from blocking other tray actions.
- Replaced the default native Yomitan settings menu with a close-only menu so closing settings does not quit the tray app. - Removed the default native app menu from Yomitan settings so File > Quit cannot put the tray app into a stuck quit state.
- 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. - 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. - 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. - 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 ::: 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 media match and snapshot without a fresh AniList lookup. 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.
::: :::
::: warning ::: 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). 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. 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`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search. **State tracking** is persisted in `character-dictionaries/auto-sync-state.json`:
```jsonc ```jsonc
{ {
+6 -8
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 wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
chmod +x ~/.local/bin/subminer chmod +x ~/.local/bin/subminer
# Download the optional Linux rofi theme # Download launcher support assets used for bundled runtime plugin injection
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz 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 tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
mkdir -p ~/.local/share/SubMiner/themes mkdir -p ~/.local/share/SubMiner/plugin/subminer
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi cp -R /tmp/plugin/subminer/. ~/.local/share/SubMiner/plugin/subminer/
``` ```
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. 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,9 +174,7 @@ subminer -u
subminer --update subminer --update
``` ```
SubMiner verifies launcher and Linux rofi theme downloads against `SHA256SUMS.txt`. If the launcher is installed in a protected path such as `/usr/local/bin/subminer`, SubMiner does not elevate itself; it shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead. SubMiner verifies 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.
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 ### From Source
@@ -242,7 +240,7 @@ subminer -u
subminer --update subminer --update
``` ```
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. 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.
::: warning Bun required for the launcher ::: 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`. 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`.
@@ -271,7 +269,7 @@ Build and install the launcher alongside the app:
make install-macos make install-macos
``` ```
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`): 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`):
```bash ```bash
sudo make install-macos PREFIX=/usr/local sudo make install-macos PREFIX=/usr/local
-2
View File
@@ -109,8 +109,6 @@ Use `subminer <subcommand> -h` for command-specific help.
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) | | `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) | | `--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. 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 ## Logging
+1 -4
View File
@@ -55,10 +55,7 @@
`bun run build` `bun run build`
When validating packaged updater output, confirm the platform build writes When validating packaged updater output, confirm the platform build writes
`*.yml` and `*.blockmap` files under `release/`. `*.yml` and `*.blockmap` files under `release/`.
5. Commit the prerelease prep (package.json version bump + the generated 5. Commit the prerelease prep. Do not run `bun run changelog:build`.
`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>`. 6. Tag the commit: `git tag v<version>`.
7. Push commit + tag. 7. Push commit + tag.
+20 -20
View File
@@ -241,36 +241,36 @@ test('dictionary command returns after app handoff starts', () => {
assert.equal(handled, true); assert.equal(handled, true);
}); });
test('update command runs direct Linux release update without launching Electron', async () => { test('update command forwards launcher path and waits for response', async () => {
const context = createContext(); const context = createContext();
context.args.update = true; context.args.update = true;
const calls: string[] = []; const forwarded: string[][] = [];
const responses: string[] = [];
const handled = await runUpdateCommand(context, { const handled = await runUpdateCommand(context, {
runAppCommandCaptureOutput: () => { createTempDir: () => '/tmp/subminer-update-test',
throw new Error('unexpected Electron launch'); joinPath: (...parts) => parts.join('/'),
runAppCommandCaptureOutput: (_appPath, appArgs) => {
forwarded.push(appArgs);
return { status: 0, stdout: '', stderr: '' };
}, },
runDirectReleaseUpdate: async (request) => { waitForUpdateResponse: async (responsePath) => {
calls.push(`direct:${request.appPath}:${request.launcherPath}:${request.channel}`); responses.push(responsePath);
return { return { ok: true, status: 'up-to-date', version: '0.15.0' };
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.equal(handled, true);
assert.deepEqual(calls, [ assert.deepEqual(forwarded, [
'direct:/tmp/subminer.app:/tmp/subminer:stable', [
'info:AppImage update: not-found', '--update',
'info:Launcher update: updated', '--update-launcher-path',
'info:Rofi theme update: skipped', '/tmp/subminer',
'--update-response-path',
'/tmp/subminer-update-test/response.json',
],
]); ]);
assert.deepEqual(responses, ['/tmp/subminer-update-test/response.json']);
}); });
test('stats command launches attached app command with response path', async () => { test('stats command launches attached app command with response path', async () => {
-140
View File
@@ -1,140 +0,0 @@
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,27 +1,10 @@
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import packageJson from '../../package.json';
import { runAppCommandCaptureOutput } from '../mpv.js'; import { runAppCommandCaptureOutput } from '../mpv.js';
import { log as launcherLog } from '../log.js';
import { nowMs } from '../time.js'; import { nowMs } from '../time.js';
import { sleep } from '../util.js'; import { sleep } from '../util.js';
import type { LauncherCommandContext } from './context.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 = { type UpdateCommandResponse = {
ok: boolean; ok: boolean;
@@ -30,18 +13,6 @@ type UpdateCommandResponse = {
error?: string; 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 = { type UpdateCommandDeps = {
createTempDir: (prefix: string) => string; createTempDir: (prefix: string) => string;
joinPath: (...parts: string[]) => string; joinPath: (...parts: string[]) => string;
@@ -51,95 +22,9 @@ type UpdateCommandDeps = {
) => { status: number; stdout: string; stderr: string; error?: Error }; ) => { status: number; stdout: string; stderr: string; error?: Error };
waitForUpdateResponse: (responsePath: string) => Promise<UpdateCommandResponse>; waitForUpdateResponse: (responsePath: string) => Promise<UpdateCommandResponse>;
removeDir: (targetPath: string) => void; 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 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 = { const defaultDeps: UpdateCommandDeps = {
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)), createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
@@ -162,9 +47,6 @@ const defaultDeps: UpdateCommandDeps = {
removeDir: (targetPath) => { removeDir: (targetPath) => {
fs.rmSync(targetPath, { recursive: true, force: true }); fs.rmSync(targetPath, { recursive: true, force: true });
}, },
runDirectReleaseUpdate,
readMainConfig: readLauncherMainConfigObject,
log: launcherLog,
}; };
export async function runUpdateCommand( export async function runUpdateCommand(
@@ -177,21 +59,6 @@ export async function runUpdateCommand(
return false; 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 tempDir = resolvedDeps.createTempDir('subminer-update-');
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json'); const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.15.0-beta.2", "version": "0.14.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "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", "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:yomitan": "bun scripts/build-yomitan.mjs",
"build:assets": "bun scripts/prepare-build-assets.mjs", "build:assets": "bun scripts/prepare-build-assets.mjs",
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer", "build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=dist/launcher/subminer",
"build:stats": "cd stats && bun run build", "build:stats": "cd stats && bun run build",
"dev:stats": "cd stats && bun run dev", "dev:stats": "cd stats && bun run dev",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets", "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
@@ -47,8 +47,8 @@
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", "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: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:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test: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/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts stats/src/styles/globals.test.ts", "test:core:src": "bun test src/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: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: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:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
+7 -58
View File
@@ -7,7 +7,7 @@
// It works with both bundled and unbundled mpv installations. // It works with both bundled and unbundled mpv installations.
// //
// Usage: swift get-mpv-window-macos.swift // Usage: swift get-mpv-window-macos.swift
// Output: "x,y,width,height,focused", "minimized", "active", "inactive", or "not-found" // Output: "x,y,width,height" or "not-found"
// //
import Cocoa import Cocoa
@@ -25,16 +25,9 @@ private struct WindowState {
let focused: Bool let focused: Bool
} }
private struct FrontmostApplicationState {
let pid: pid_t
let isMpv: Bool
}
private enum WindowLookupResult { private enum WindowLookupResult {
case visible(WindowState) case visible(WindowState)
case minimized case minimized
case active
case inactive
} }
private let targetMpvSocketPath: String? = { private let targetMpvSocketPath: String? = {
@@ -153,41 +146,8 @@ private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? {
return geometry return geometry
} }
private func frontmostApplicationState() -> FrontmostApplicationState? { private func frontmostApplicationPid() -> pid_t? {
guard let app = NSWorkspace.shared.frontmostApplication else { NSWorkspace.shared.frontmostApplication?.processIdentifier
return nil
}
return FrontmostApplicationState(
pid: app.processIdentifier,
isMpv: app.localizedName.map(normalizedMpvName) ?? false
)
}
private func isFocusedMpvWindow(ownerPid: pid_t, frontmost: FrontmostApplicationState?) -> Bool {
guard let frontmost = frontmost else {
return false
}
if frontmost.pid == ownerPid {
return true
}
return frontmost.isMpv && windowHasTargetSocket(ownerPid)
}
private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool {
guard let frontmost = frontmost, frontmost.isMpv else {
return false
}
if windowHasTargetSocket(frontmost.pid) {
return true
}
// When macOS says mpv is frontmost but geometry APIs miss, keep the
// overlay stable even if ps cannot expose the socket argument.
return targetMpvSocketPath != nil
} }
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? { private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
@@ -198,7 +158,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
return normalizedMpvName(name) return normalizedMpvName(name)
} }
let frontmost = frontmostApplicationState() let frontmostPid = frontmostApplicationPid()
var foundMinimizedTargetWindow = false var foundMinimizedTargetWindow = false
for app in runningApps { for app in runningApps {
@@ -238,7 +198,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
return .visible( return .visible(
WindowState( WindowState(
geometry: geometry, geometry: geometry,
focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost) focused: frontmostPid == windowPid
) )
) )
} }
@@ -257,7 +217,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
// Use on-screen layer-0 windows to avoid off-screen helpers/shadows. // Use on-screen layer-0 windows to avoid off-screen helpers/shadows.
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? [] let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
let frontmost = frontmostApplicationState() let frontmostPid = frontmostApplicationPid()
for window in windowList { for window in windowList {
guard let ownerName = window[kCGWindowOwnerName as String] as? String, guard let ownerName = window[kCGWindowOwnerName as String] as? String,
@@ -300,7 +260,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
return WindowState( return WindowState(
geometry: geometry, geometry: geometry,
focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost) focused: frontmostPid == ownerPid
) )
} }
@@ -314,13 +274,6 @@ private let lookupResult: WindowLookupResult? = {
if let cgWindow = windowStateFromCoreGraphics() { if let cgWindow = windowStateFromCoreGraphics() {
return .visible(cgWindow) return .visible(cgWindow)
} }
let frontmost = frontmostApplicationState()
if isFrontmostTargetMpv(frontmost) {
return .active
}
if frontmost != nil {
return .inactive
}
return nil return nil
}() }()
@@ -332,10 +285,6 @@ if let result = lookupResult {
) )
case .minimized: case .minimized:
print("minimized") print("minimized")
case .active:
print("active")
case .inactive:
print("inactive")
} }
} else { } else {
print("not-found") print("not-found")
-61
View File
@@ -31,64 +31,3 @@ test('minimized Accessibility windows are validated by PID and socket before rep
'target socket must be validated before accepting a minimized window', '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',
);
});
+4 -17
View File
@@ -130,8 +130,8 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
openYomitanSettingsDelayed: (delayMs) => { openYomitanSettingsDelayed: (delayMs) => {
calls.push(`openYomitanSettingsDelayed:${delayMs}`); calls.push(`openYomitanSettingsDelayed:${delayMs}`);
}, },
openFirstRunSetup: (force?: boolean) => { openFirstRunSetup: () => {
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`); calls.push('openFirstRunSetup');
}, },
setVisibleOverlayVisible: (visible) => { setVisibleOverlayVisible: (visible) => {
calls.push(`setVisibleOverlayVisible:${visible}`); calls.push(`setVisibleOverlayVisible:${visible}`);
@@ -247,9 +247,6 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
log: (message) => { log: (message) => {
calls.push(`log:${message}`); calls.push(`log:${message}`);
}, },
logDebug: (message) => {
calls.push(`debug:${message}`);
},
warn: (message) => { warn: (message) => {
calls.push(`warn:${message}`); calls.push(`warn:${message}`);
}, },
@@ -361,23 +358,13 @@ 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', () => { test('handleCliCommand opens first-run setup window for --setup', () => {
const { deps, calls } = createDeps(); const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ setup: true }), 'initial', deps); handleCliCommand(makeArgs({ setup: true }), 'initial', deps);
assert.ok(calls.includes('openFirstRunSetup:force')); assert.ok(calls.includes('openFirstRunSetup'));
assert.ok(calls.includes('debug:Opened first-run setup flow.')); assert.ok(calls.includes('log:Opened first-run setup flow.'));
assert.equal(calls.includes('log:Opened first-run setup flow.'), false);
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false); assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
}); });
+4 -7
View File
@@ -41,7 +41,7 @@ export interface CliCommandServiceDeps {
initializeOverlayRuntime: () => void; initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void; togglePrimarySubtitleBar: () => void;
openFirstRunSetup: (force?: boolean) => void; openFirstRunSetup: () => void;
openYomitanSettingsDelayed: (delayMs: number) => void; openYomitanSettingsDelayed: (delayMs: number) => void;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
@@ -106,7 +106,6 @@ export interface CliCommandServiceDeps {
getMultiCopyTimeoutMs: () => number; getMultiCopyTimeoutMs: () => number;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
log: (message: string) => void; log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void; warn: (message: string) => void;
error: (message: string, err: unknown) => void; error: (message: string, err: unknown) => void;
} }
@@ -158,7 +157,7 @@ interface MiningCliRuntime {
} }
interface UiCliRuntime { interface UiCliRuntime {
openFirstRunSetup: (force?: boolean) => void; openFirstRunSetup: () => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
cycleSecondarySubMode: () => void; cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
@@ -212,7 +211,6 @@ export interface CliCommandDepsRuntimeOptions {
getMultiCopyTimeoutMs: () => number; getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => unknown; schedule: (fn: () => void, delayMs: number) => unknown;
log: (message: string) => void; log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void; warn: (message: string) => void;
error: (message: string, err: unknown) => void; error: (message: string, err: unknown) => void;
} }
@@ -288,7 +286,6 @@ export function createCliCommandDepsRuntime(
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
showMpvOsd: options.mpv.showOsd, showMpvOsd: options.mpv.showOsd,
log: options.log, log: options.log,
logDebug: options.logDebug,
warn: options.warn, warn: options.warn,
error: options.error, error: options.error,
}; };
@@ -381,8 +378,8 @@ export function handleCliCommand(
} else if (args.togglePrimarySubtitleBar) { } else if (args.togglePrimarySubtitleBar) {
deps.togglePrimarySubtitleBar(); deps.togglePrimarySubtitleBar();
} else if (args.setup) { } else if (args.setup) {
deps.openFirstRunSetup(true); deps.openFirstRunSetup();
deps.logDebug('Opened first-run setup flow.'); deps.log('Opened first-run setup flow.');
} else if (args.settings) { } else if (args.settings) {
deps.openYomitanSettingsDelayed(1000); deps.openYomitanSettingsDelayed(1000);
} else if (args.show || args.showVisibleOverlay) { } else if (args.show || args.showVisibleOverlay) {
+1 -32
View File
@@ -218,9 +218,6 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
getMainWindow: () => null, getMainWindow: () => null,
getVisibleOverlayVisibility: () => false, getVisibleOverlayVisibility: () => false,
onOverlayModalClosed: () => {}, onOverlayModalClosed: () => {},
onOverlayMouseInteractionChanged: (active) => {
calls.push(`overlay-interaction:${active}`);
},
openYomitanSettings: () => {}, openYomitanSettings: () => {},
quitApp: () => {}, quitApp: () => {},
toggleVisibleOverlay: () => {}, toggleVisibleOverlay: () => {},
@@ -284,7 +281,6 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' }); assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
deps.clearAnilistToken(); deps.clearAnilistToken();
deps.openAnilistSetup(); deps.openAnilistSetup();
deps.onOverlayMouseInteractionChanged?.(true, null);
assert.deepEqual(deps.getAnilistQueueStatus(), { assert.deepEqual(deps.getAnilistQueueStatus(), {
pending: 1, pending: 1,
ready: 0, ready: 0,
@@ -302,37 +298,10 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' }); assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' }); assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' }); assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
assert.deepEqual(calls, [ assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
'clearAnilistToken',
'openAnilistSetup',
'overlay-interaction:true',
'retryAnilistQueueNow',
]);
assert.equal(deps.getPlaybackPaused(), true); 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 () => { test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
const { registrar, handlers } = createFakeIpcRegistrar(); const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = []; const calls: string[] = [];
-10
View File
@@ -44,10 +44,6 @@ export interface IpcServiceDeps {
modal: OverlayHostedModal, modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null, senderWindow: ElectronBrowserWindow | null,
) => void; ) => void;
onOverlayMouseInteractionChanged?: (
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
quitApp: () => void; quitApp: () => void;
toggleDevTools: () => void; toggleDevTools: () => void;
@@ -179,10 +175,6 @@ export interface IpcDepsRuntimeOptions {
modal: OverlayHostedModal, modal: OverlayHostedModal,
senderWindow: ElectronBrowserWindow | null, senderWindow: ElectronBrowserWindow | null,
) => void; ) => void;
onOverlayMouseInteractionChanged?: (
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void; openYomitanSettings: () => void;
quitApp: () => void; quitApp: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
@@ -241,7 +233,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
return { return {
onOverlayModalClosed: options.onOverlayModalClosed, onOverlayModalClosed: options.onOverlayModalClosed,
onOverlayModalOpened: options.onOverlayModalOpened, onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
openYomitanSettings: options.openYomitanSettings, openYomitanSettings: options.openYomitanSettings,
quitApp: options.quitApp, quitApp: options.quitApp,
toggleDevTools: () => { toggleDevTools: () => {
@@ -358,7 +349,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
if (senderWindow && !senderWindow.isDestroyed()) { if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions); senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
} }
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
}, },
); );
+6 -351
View File
@@ -42,11 +42,6 @@ function createMainWindowRecorder() {
setAlwaysOnTop: (flag: boolean) => { setAlwaysOnTop: (flag: boolean) => {
calls.push(`always-on-top:${flag}`); 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 }) => { setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`); calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
}, },
@@ -543,12 +538,11 @@ test('forced passthrough still shows tracked overlay while bound to mpv on Windo
assert.ok(calls.includes('sync-windows-z-order')); assert.ok(calls.includes('sync-windows-z-order'));
}); });
test('forced mouse passthrough keeps macOS tracked overlay above active mpv', () => { test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
isTracking: () => true, isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
}; };
updateVisibleOverlayVisibility({ updateVisibleOverlayVisibility({
@@ -577,53 +571,8 @@ test('forced mouse passthrough keeps macOS tracked overlay above active mpv', ()
forceMousePassthrough: true, forceMousePassthrough: true,
} as never); } 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('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false')); 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('ensure-level'));
assert.ok(!calls.includes('enforce-order')); assert.ok(!calls.includes('enforce-order'));
}); });
@@ -967,8 +916,7 @@ test('macOS tracked visible overlay starts click-through without passively steal
} as never); } as never);
assert.ok(calls.includes('mouse-ignore:true:forward')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show-inactive')); assert.ok(calls.includes('show'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus')); assert.ok(!calls.includes('focus'));
}); });
@@ -1061,7 +1009,7 @@ test('macOS keeps active mpv overlay visible and click-through during tracker re
assert.deepEqual(osdMessages, []); assert.deepEqual(osdMessages, []);
}); });
test('macOS tracked overlay hides when mpv loses foreground', () => { test('macOS tracked overlay releases topmost level when mpv loses foreground', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
isTracking: () => true, isTracking: () => true,
@@ -1069,9 +1017,6 @@ test('macOS tracked overlay hides when mpv loses foreground', () => {
isTargetWindowFocused: () => false, isTargetWindowFocused: () => false,
}; };
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({ updateVisibleOverlayVisibility({
visibleOverlayVisible: true, visibleOverlayVisible: true,
mainWindow: window as never, mainWindow: window as never,
@@ -1101,202 +1046,14 @@ test('macOS tracked overlay hides when mpv loses foreground', () => {
assert.ok(calls.includes('sync-layer')); assert.ok(calls.includes('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false')); assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('all-workspaces:false:plain')); assert.ok(calls.includes('show'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('sync-shortcuts')); assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('ensure-level')); assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order')); assert.ok(!calls.includes('enforce-order'));
assert.ok(!calls.includes('focus')); 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')); 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', () => { test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const osdMessages: string[] = []; const osdMessages: string[] = [];
@@ -1384,8 +1141,7 @@ test('forced mouse passthrough keeps macOS tracked overlay passive while visible
} as never); } as never);
assert.ok(calls.includes('mouse-ignore:true:forward')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show-inactive')); assert.ok(calls.includes('show'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus')); assert.ok(!calls.includes('focus'));
}); });
@@ -1682,7 +1438,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
assert.ok(!calls.includes('show')); assert.ok(!calls.includes('show'));
}); });
test('macOS hides visible overlay during tracker loss after mpv loses foreground', () => { test('macOS preserves visible overlay level during non-minimized tracker loss', () => {
const { window, calls } = createMainWindowRecorder(); const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
isTracking: () => false, isTracking: () => false,
@@ -1721,114 +1477,13 @@ test('macOS hides visible overlay during tracker loss after mpv loses foreground
}, },
} as never); } 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('sync-layer'));
assert.ok(calls.includes('mouse-ignore:true:forward')); assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensure-level')); assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order')); assert.ok(calls.includes('enforce-order'));
assert.ok(calls.includes('sync-shortcuts')); assert.ok(calls.includes('sync-shortcuts'));
assert.ok(!calls.includes('hide')); 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('always-on-top:false'));
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('loading-osd')); assert.ok(!calls.includes('loading-osd'));
}); });
+8 -53
View File
@@ -15,17 +15,6 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
opacityCapableWindow.setOpacity?.(opacity); 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 { function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window); const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
if (!pendingTimeout) { if (!pendingTimeout) {
@@ -63,7 +52,6 @@ export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean; visibleOverlayVisible: boolean;
modalActive?: boolean; modalActive?: boolean;
forceMousePassthrough?: boolean; forceMousePassthrough?: boolean;
overlayInteractionActive?: boolean;
mainWindow: BrowserWindow | null; mainWindow: BrowserWindow | null;
windowTracker: BaseWindowTracker | null; windowTracker: BaseWindowTracker | null;
lastKnownWindowsForegroundProcessName?: string | null; lastKnownWindowsForegroundProcessName?: string | null;
@@ -90,7 +78,6 @@ export function updateVisibleOverlayVisibility(args: {
} }
const mainWindow = args.mainWindow; const mainWindow = args.mainWindow;
const overlayInteractionActive = args.overlayInteractionActive === true;
if (args.modalActive) { if (args.modalActive) {
if (args.isWindowsPlatform) { if (args.isWindowsPlatform) {
@@ -106,26 +93,23 @@ export function updateVisibleOverlayVisibility(args: {
const forceMousePassthrough = args.forceMousePassthrough === true; const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible(); const wasVisible = mainWindow.isVisible();
const isVisibleOverlayFocused = const isVisibleOverlayFocused =
overlayInteractionActive || typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
const windowTracker = args.windowTracker; const windowTracker = args.windowTracker;
const canReportMacOSTargetMinimized = const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function'; args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
const isTrackedMacOSTargetMinimized = const isTrackedMacOSTargetMinimized =
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true; canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
const hasTransientMacOSTrackerLoss = const hasTransientMacOSTrackerLoss =
args.isMacOSPlatform && args.isMacOSPlatform &&
canReportMacOSTargetMinimized && canReportMacOSTargetMinimized &&
!!windowTracker && !!windowTracker &&
!windowTracker.isTracking() && !windowTracker.isTracking() &&
!isTrackedMacOSTargetMinimized && !isTrackedMacOSTargetMinimized &&
trackedMacOSTargetFocused !== false &&
mainWindow.isVisible(); mainWindow.isVisible();
const isTrackedMacOSTargetFocused = const isTrackedMacOSTargetFocused =
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
? true ? true
: (trackedMacOSTargetFocused ?? true); : (args.windowTracker.isTargetWindowFocused?.() ?? true);
const shouldReleaseMacOSOverlayLevel = const shouldReleaseMacOSOverlayLevel =
args.isMacOSPlatform && args.isMacOSPlatform &&
!!args.windowTracker && !!args.windowTracker &&
@@ -133,7 +117,7 @@ export function updateVisibleOverlayVisibility(args: {
!isVisibleOverlayFocused && !isVisibleOverlayFocused &&
!isTrackedMacOSTargetFocused; !isTrackedMacOSTargetFocused;
// Renderer hover tracking temporarily disables this for subtitle and popup interaction. // Renderer hover tracking temporarily disables this for subtitle and popup interaction.
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive; const shouldUseMacOSMousePassthrough = args.isMacOSPlatform;
const shouldDefaultToPassthrough = const shouldDefaultToPassthrough =
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel; args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
const windowsForegroundProcessName = const windowsForegroundProcessName =
@@ -175,22 +159,14 @@ export function updateVisibleOverlayVisibility(args: {
mainWindow.setIgnoreMouseEvents(false); mainWindow.setIgnoreMouseEvents(false);
} }
if (shouldReleaseMacOSOverlayLevel) {
releaseOverlayWindowLevel(mainWindow);
if (wasVisible) {
mainWindow.hide();
}
return false;
}
if (shouldBindTrackedWindowsOverlay) { if (shouldBindTrackedWindowsOverlay) {
// On Windows, z-order is enforced by the OS via the owner window mechanism // On Windows, z-order is enforced by the OS via the owner window mechanism
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv // (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
// without any manual z-order management. // without any manual z-order management.
} else if (!forceMousePassthrough || args.isMacOSPlatform) { } else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) {
args.ensureOverlayWindowLevel(mainWindow); args.ensureOverlayWindowLevel(mainWindow);
} else { } else {
releaseOverlayWindowLevel(mainWindow); mainWindow.setAlwaysOnTop(false);
} }
if (!wasVisible) { if (!wasVisible) {
const hasWebContents = const hasWebContents =
@@ -203,20 +179,16 @@ export function updateVisibleOverlayVisibility(args: {
// skip — ready-to-show hasn't fired yet; the onWindowContentReady // skip — ready-to-show hasn't fired yet; the onWindowContentReady
// callback will trigger another visibility update when the renderer // callback will trigger another visibility update when the renderer
// has painted its first frame. // has painted its first frame.
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) { } else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) {
if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0); setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.showInactive(); mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true }); mainWindow.setIgnoreMouseEvents(true, { forward: true });
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal( scheduleWindowsOverlayReveal(
mainWindow, mainWindow,
shouldBindTrackedWindowsOverlay shouldBindTrackedWindowsOverlay
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window) ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
: undefined, : undefined,
); );
}
} else { } else {
if (args.isWindowsPlatform) { if (args.isWindowsPlatform) {
setOverlayWindowOpacity(mainWindow, 0); setOverlayWindowOpacity(mainWindow, 0);
@@ -237,16 +209,6 @@ export function updateVisibleOverlayVisibility(args: {
args.syncWindowsOverlayToMpvZOrder?.(mainWindow); args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
} }
if (
args.isMacOSPlatform &&
overlayInteractionActive &&
!forceMousePassthrough &&
typeof mainWindow.isFocused === 'function' &&
!mainWindow.isFocused()
) {
mainWindow.focus();
}
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) { if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
mainWindow.focus(); mainWindow.focus();
} }
@@ -254,11 +216,6 @@ export function updateVisibleOverlayVisibility(args: {
return !shouldReleaseMacOSOverlayLevel; return !shouldReleaseMacOSOverlayLevel;
}; };
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
shouldEnforceLayerOrder &&
!args.isWindowsPlatform &&
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
const maybeShowOverlayLoadingOsd = (): void => { const maybeShowOverlayLoadingOsd = (): void => {
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) { if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
return; return;
@@ -301,7 +258,7 @@ export function updateVisibleOverlayVisibility(args: {
} }
args.syncPrimaryOverlayWindowLayer('visible'); args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay(); const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) { if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
args.enforceOverlayLayerOrder(); args.enforceOverlayLayerOrder();
} }
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
@@ -333,7 +290,6 @@ export function updateVisibleOverlayVisibility(args: {
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null; const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
const hasActiveMacOSTargetSignal = const hasActiveMacOSTargetSignal =
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false); args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
const hasActiveMacOSOverlaySignal = args.isMacOSPlatform && overlayInteractionActive;
const canReportMacOSTargetMinimized = const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function'; args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
const isTrackedMacOSTargetMinimized = const isTrackedMacOSTargetMinimized =
@@ -342,7 +298,6 @@ export function updateVisibleOverlayVisibility(args: {
(args.isMacOSPlatform && (args.isMacOSPlatform &&
!isTrackedMacOSTargetMinimized && !isTrackedMacOSTargetMinimized &&
(hasRetainedTrackedGeometry || (hasRetainedTrackedGeometry ||
(mainWindow.isVisible() && hasActiveMacOSOverlaySignal) ||
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) || (mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) || (canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
(args.isWindowsPlatform && (args.isWindowsPlatform &&
@@ -360,7 +315,7 @@ export function updateVisibleOverlayVisibility(args: {
} }
args.syncPrimaryOverlayWindowLayer('visible'); args.syncPrimaryOverlayWindowLayer('visible');
const shouldEnforceLayerOrder = showPassiveVisibleOverlay(); const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) { if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
args.enforceOverlayLayerOrder(); args.enforceOverlayLayerOrder();
} }
args.syncOverlayShortcuts(); args.syncOverlayShortcuts();
+2 -3
View File
@@ -66,16 +66,15 @@ export function handleOverlayWindowBlurred(options: {
isOverlayVisible: (kind: OverlayWindowKind) => boolean; isOverlayVisible: (kind: OverlayWindowKind) => boolean;
ensureOverlayWindowLevel: () => void; ensureOverlayWindowLevel: () => void;
moveWindowTop: () => void; moveWindowTop: () => void;
onVisibleOverlayBlur?: () => void; onWindowsVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
}): boolean { }): boolean {
const platform = options.platform ?? process.platform; const platform = options.platform ?? process.platform;
if (platform === 'win32' && options.kind === 'visible') { if (platform === 'win32' && options.kind === 'visible') {
options.onVisibleOverlayBlur?.(); options.onWindowsVisibleOverlayBlur?.();
return false; return false;
} }
if (platform === 'darwin' && options.kind === 'visible') { if (platform === 'darwin' && options.kind === 'visible') {
options.onVisibleOverlayBlur?.();
return false; return false;
} }
+7 -7
View File
@@ -136,14 +136,14 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
moveWindowTop: () => { moveWindowTop: () => {
calls.push('move-top'); calls.push('move-top');
}, },
onVisibleOverlayBlur: () => { onWindowsVisibleOverlayBlur: () => {
calls.push('visible-blur'); calls.push('windows-visible-blur');
}, },
platform: 'win32', platform: 'win32',
}); });
assert.equal(handled, false); assert.equal(handled, false);
assert.deepEqual(calls, ['visible-blur']); assert.deepEqual(calls, ['windows-visible-blur']);
}); });
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => { 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, []); assert.deepEqual(calls, []);
}); });
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => { test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visible overlay blur', () => {
const calls: string[] = []; const calls: string[] = [];
const handled = handleOverlayWindowBlurred({ const handled = handleOverlayWindowBlurred({
@@ -179,14 +179,14 @@ test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback wi
moveWindowTop: () => { moveWindowTop: () => {
calls.push('move-top'); calls.push('move-top');
}, },
onVisibleOverlayBlur: () => { onWindowsVisibleOverlayBlur: () => {
calls.push('visible-blur'); calls.push('windows-visible-blur');
}, },
platform: 'darwin', platform: 'darwin',
}); });
assert.equal(handled, false); assert.equal(handled, false);
assert.deepEqual(calls, ['visible-blur']); assert.deepEqual(calls, []);
}); });
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => { test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
+1 -1
View File
@@ -180,7 +180,7 @@ export function createOverlayWindow(
moveWindowTop: () => { moveWindowTop: () => {
window.moveTop(); window.moveTop();
}, },
onVisibleOverlayBlur: onWindowsVisibleOverlayBlur:
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined, kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
}); });
}); });
-86
View File
@@ -358,89 +358,3 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime'
assert.ok(calls.indexOf('init-overlay') !== -1); assert.ok(calls.indexOf('init-overlay') !== -1);
assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay')); 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);
});
+12 -7
View File
@@ -131,7 +131,6 @@ export interface AppReadyRuntimeDeps {
createImmersionTracker?: () => void; createImmersionTracker?: () => void;
startJellyfinRemoteSession?: () => Promise<void>; startJellyfinRemoteSession?: () => Promise<void>;
loadYomitanExtension: () => Promise<void>; loadYomitanExtension: () => Promise<void>;
ensureYomitanExtensionLoaded?: () => Promise<void>;
handleFirstRunSetup: () => Promise<void>; handleFirstRunSetup: () => Promise<void>;
prewarmSubtitleDictionaries?: () => Promise<void>; prewarmSubtitleDictionaries?: () => Promise<void>;
startBackgroundWarmups: () => void; startBackgroundWarmups: () => void;
@@ -216,8 +215,6 @@ export function isAutoUpdateEnabledRuntime(
export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> { export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<void> {
const now = deps.now ?? (() => Date.now()); const now = deps.now ?? (() => Date.now());
const startupStartedAtMs = now(); const startupStartedAtMs = now();
const ensureYomitanExtensionReady =
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
deps.ensureDefaultConfigBootstrap(); deps.ensureDefaultConfigBootstrap();
if (deps.shouldRunHeadlessInitialCommand?.()) { if (deps.shouldRunHeadlessInitialCommand?.()) {
deps.reloadConfig(); deps.reloadConfig();
@@ -227,7 +224,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
} else { } else {
deps.createMpvClient(); deps.createMpvClient();
deps.createSubtitleTimingTracker(); deps.createSubtitleTimingTracker();
await ensureYomitanExtensionReady(); await deps.loadYomitanExtension();
deps.initializeOverlayRuntime(); deps.initializeOverlayRuntime();
deps.handleInitialArgs(); deps.handleInitialArgs();
} }
@@ -240,10 +237,18 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
return; return;
} }
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
return;
}
deps.logDebug?.('App-ready critical path started.'); deps.logDebug?.('App-ready critical path started.');
if (deps.shouldSkipHeavyStartup?.()) { if (deps.shouldSkipHeavyStartup?.()) {
await ensureYomitanExtensionReady(); await deps.loadYomitanExtension();
deps.reloadConfig(); deps.reloadConfig();
await deps.handleFirstRunSetup(); await deps.handleFirstRunSetup();
deps.handleInitialArgs(); deps.handleInitialArgs();
@@ -314,12 +319,12 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.texthookerOnlyMode) { if (deps.texthookerOnlyMode) {
deps.log('Texthooker-only mode enabled; skipping overlay window.'); deps.log('Texthooker-only mode enabled; skipping overlay window.');
} else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) { } else if (deps.shouldAutoInitializeOverlayRuntimeFromConfig()) {
await ensureYomitanExtensionReady(); await deps.loadYomitanExtension();
deps.setVisibleOverlayVisible(true); deps.setVisibleOverlayVisible(true);
deps.initializeOverlayRuntime(); deps.initializeOverlayRuntime();
} else { } else {
deps.log('Overlay runtime deferred: waiting for explicit overlay command.'); deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
await ensureYomitanExtensionReady(); await deps.loadYomitanExtension();
} }
await deps.handleFirstRunSetup(); await deps.handleFirstRunSetup();
-19
View File
@@ -9,8 +9,6 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>; Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>; type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
Partial<Pick<BrowserWindow, 'showInactive'>>;
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean { function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
return ( return (
@@ -106,23 +104,6 @@ export function promoteStatsWindowLevel(
window.moveTop(); 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): { export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
query: Record<string, string>; query: Record<string, string>;
} { } {
-43
View File
@@ -3,7 +3,6 @@ import test from 'node:test';
import { import {
buildStatsWindowLoadFileOptions, buildStatsWindowLoadFileOptions,
buildStatsWindowOptions, buildStatsWindowOptions,
presentStatsWindow,
promoteStatsWindowLevel, promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent, resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
@@ -231,45 +230,3 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () =
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']); 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,7 +5,6 @@ import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
import { import {
buildStatsWindowLoadFileOptions, buildStatsWindowLoadFileOptions,
buildStatsWindowOptions, buildStatsWindowOptions,
presentStatsWindow,
promoteStatsWindowLevel, promoteStatsWindowLevel,
resolveStatsWindowOuterBoundsForContent, resolveStatsWindowOuterBoundsForContent,
shouldHideStatsWindowForInput, shouldHideStatsWindowForInput,
@@ -50,13 +49,14 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
const bounds = options.resolveBounds(); const bounds = options.resolveBounds();
let placementBounds = syncStatsWindowBounds(window, bounds); let placementBounds = syncStatsWindowBounds(window, bounds);
promoteStatsWindowLevel(window); promoteStatsWindowLevel(window);
presentStatsWindow(window); window.show();
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds; placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
if ( if (
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds }) !ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
) { ) {
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds; placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
} }
window.focus();
options.onVisibilityChanged?.(true); options.onVisibilityChanged?.(true);
promoteStatsWindowLevel(window); promoteStatsWindowLevel(window);
} }
@@ -9,7 +9,6 @@ import {
ensureExtensionCopyAsync, ensureExtensionCopyAsync,
shouldCopyYomitanExtension, shouldCopyYomitanExtension,
} from './yomitan-extension-copy'; } from './yomitan-extension-copy';
import { withSuppressedYomitanExtensionWarnings } from './yomitan-extension-loader';
function makeTempDir(prefix: string): string { function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -20,66 +19,6 @@ function writeFile(filePath: string, content: string): void {
fs.writeFileSync(filePath, content, 'utf-8'); 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', () => { test('shouldCopyYomitanExtension detects popup runtime script drift', () => {
const tempRoot = makeTempDir('subminer-yomitan-copy-'); const tempRoot = makeTempDir('subminer-yomitan-copy-');
const sourceDir = path.join(tempRoot, 'source'); const sourceDir = path.join(tempRoot, 'source');
@@ -246,7 +185,10 @@ test('ensureExtensionCopyAsync shares an in-flight refresh for the same copied e
assert.equal(results[0].copied, true); assert.equal(results[0].copied, true);
assert.equal(results[1].copied, true); assert.equal(results[1].copied, true);
assert.equal( 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', 'new settings code',
); );
} finally { } finally {
+6 -98
View File
@@ -29,85 +29,6 @@ export interface YomitanExtensionLoaderDeps {
setYomitanSession: (session: Session | null) => void; 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( export async function loadYomitanExtension(
deps: YomitanExtensionLoaderDeps, deps: YomitanExtensionLoaderDeps,
): Promise<Extension | null> { ): Promise<Extension | null> {
@@ -158,20 +79,9 @@ export async function loadYomitanExtension(
return null; return null;
} }
let extensionCopy: { copied: boolean; targetDir: string }; const extensionCopy = await ensureExtensionCopyAsync(extPath, deps.userDataPath);
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) { if (extensionCopy.copied) {
logger.debug(`Copied yomitan extension to ${extensionCopy.targetDir}`); logger.info(`Copied yomitan extension to ${extensionCopy.targetDir}`);
} }
extPath = extensionCopy.targetDir; extPath = extensionCopy.targetDir;
} }
@@ -181,15 +91,13 @@ export async function loadYomitanExtension(
try { try {
const extensions = targetSession.extensions; const extensions = targetSession.extensions;
const extension = await withSuppressedYomitanExtensionWarnings(() => const extension = extensions
extensions ? await extensions.loadExtension(extPath, {
? extensions.loadExtension(extPath, {
allowFileAccess: true, allowFileAccess: true,
}) })
: targetSession.loadExtension(extPath, { : await targetSession.loadExtension(extPath, {
allowFileAccess: true, allowFileAccess: true,
}), });
);
deps.setYomitanExtension(extension); deps.setYomitanExtension(extension);
return extension; return extension;
} catch (err) { } catch (err) {
+3 -77
View File
@@ -2,101 +2,27 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
buildYomitanSettingsCloseButtonScript,
buildYomitanSettingsWindowMenuTemplate,
buildYomitanSettingsUrl, buildYomitanSettingsUrl,
configureYomitanSettingsWindowChrome, configureYomitanSettingsWindowChrome,
destroyYomitanSettingsWindow, destroyYomitanSettingsWindow,
installYomitanSettingsCloseButton,
showYomitanSettingsWindow, showYomitanSettingsWindow,
shouldInstallYomitanSettingsCloseButton,
} from './yomitan-settings'; } from './yomitan-settings';
test('yomitan settings window uses a close-only menu without app quit', () => { test('yomitan settings window removes default app menu quit action', () => {
const calls: string[] = []; const calls: string[] = [];
configureYomitanSettingsWindowChrome({ configureYomitanSettingsWindowChrome({
isDestroyed: () => false,
close: () => calls.push('close'),
setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`), setAutoHideMenuBar: (hide: boolean) => calls.push(`auto-hide:${hide}`),
setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`), setMenu: (menu: unknown) => calls.push(`menu:${menu === null ? 'null' : 'custom'}`),
} 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: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); } 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', () => { assert.deepEqual(calls, ['auto-hide:true', 'menu:null']);
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', () => { test('yomitan settings URL disables the embedded popup preview', () => {
assert.equal( assert.equal(
buildYomitanSettingsUrl('abc123'), buildYomitanSettingsUrl('abc123'),
'chrome-extension://abc123/settings.html?popup-preview=false', 'chrome-extension://abc123/settings.html?popup-preview=false&subminer-settings-safe=true',
); );
}); });
+6 -119
View File
@@ -1,8 +1,8 @@
import electron from 'electron'; import electron from 'electron';
import type { BrowserWindow, Extension, Menu, MenuItemConstructorOptions, Session } from 'electron'; import type { BrowserWindow, Extension, Session } from 'electron';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
const { BrowserWindow: ElectronBrowserWindow, Menu: ElectronMenu, session } = electron; const { BrowserWindow: ElectronBrowserWindow, session } = electron;
const logger = createLogger('main:yomitan-settings'); const logger = createLogger('main:yomitan-settings');
export interface OpenYomitanSettingsWindowOptions { export interface OpenYomitanSettingsWindowOptions {
@@ -13,127 +13,15 @@ export interface OpenYomitanSettingsWindowOptions {
onWindowClosed?: () => void; onWindowClosed?: () => void;
} }
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 {
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( export function configureYomitanSettingsWindowChrome(
settingsWindow: Pick<BrowserWindow, 'close' | 'isDestroyed' | 'setAutoHideMenuBar' | 'setMenu'>, settingsWindow: Pick<BrowserWindow, 'setAutoHideMenuBar' | 'setMenu'>,
buildMenu: (template: MenuItemConstructorOptions[]) => Menu = (template) =>
ElectronMenu.buildFromTemplate(template),
): void { ): void {
settingsWindow.setAutoHideMenuBar(false); settingsWindow.setAutoHideMenuBar(true);
settingsWindow.setMenu(buildMenu(buildYomitanSettingsWindowMenuTemplate(settingsWindow))); settingsWindow.setMenu(null);
} }
export function buildYomitanSettingsUrl(extensionId: string): string { export function buildYomitanSettingsUrl(extensionId: string): string {
return `chrome-extension://${extensionId}/settings.html?popup-preview=false`; return `chrome-extension://${extensionId}/settings.html?popup-preview=false&subminer-settings-safe=true`;
} }
export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void { export function showYomitanSettingsWindow(settingsWindow: BrowserWindow): void {
@@ -220,7 +108,6 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti
settingsWindow.webContents.on('did-finish-load', () => { settingsWindow.webContents.on('did-finish-load', () => {
logger.info('Settings page loaded successfully'); logger.info('Settings page loaded successfully');
installYomitanSettingsCloseButton(settingsWindow);
}); });
setTimeout(() => { setTimeout(() => {
+31 -85
View File
@@ -82,7 +82,6 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
return { return {
shouldUseMinimalStartup: Boolean( shouldUseMinimalStartup: Boolean(
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) || (initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
initialArgs?.update ||
(initialArgs?.stats && (initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)), (initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
), ),
@@ -91,7 +90,6 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
(shouldRunSettingsOnlyStartup(initialArgs) || (shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.stats || initialArgs.stats ||
initialArgs.dictionary || initialArgs.dictionary ||
initialArgs.update ||
initialArgs.setup), initialArgs.setup),
), ),
}; };
@@ -367,7 +365,6 @@ import { toggleStatsOverlay as toggleStatsOverlayWindow } from './core/services/
import { import {
createFirstRunSetupService, createFirstRunSetupService,
getFirstRunSetupCompletionMessage, getFirstRunSetupCompletionMessage,
isStandaloneFirstRunSetupCommand,
shouldAutoOpenFirstRunSetup, shouldAutoOpenFirstRunSetup,
} from './main/runtime/first-run-setup-service'; } from './main/runtime/first-run-setup-service';
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow'; import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
@@ -511,21 +508,22 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer'; import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
import { import { createElectronAppUpdater } from './main/runtime/update/app-updater';
createElectronAppUpdater,
isNativeUpdaterSupported,
} from './main/runtime/update/app-updater';
import { import {
fetchLatestStableRelease, fetchLatestStableRelease,
fetchReleaseAssetBuffer, fetchReleaseAssetBuffer,
fetchReleaseAssetText, fetchReleaseAssetText,
findReleaseAsset, findReleaseAsset,
parseSha256Sums, parseSha256Sums,
type GitHubRelease,
} from './main/runtime/update/release-assets'; } from './main/runtime/update/release-assets';
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater'; import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications'; import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
import { createUpdateDialogPresenter } from './main/runtime/update/update-dialogs'; import {
showNoUpdateDialog,
showRestartDialog,
showUpdateAvailableDialog,
showUpdateFailedDialog,
} from './main/runtime/update/update-dialogs';
import { import {
runUpdateCliCommand, runUpdateCliCommand,
writeUpdateCliCommandResponse, writeUpdateCliCommandResponse,
@@ -849,9 +847,6 @@ const appLogger = {
logInfo: (message: string) => { logInfo: (message: string) => {
logger.info(message); logger.info(message);
}, },
logDebug: (message: string) => {
logger.debug(message);
},
logWarning: (message: string) => { logWarning: (message: string) => {
logger.warn(message); logger.warn(message);
}, },
@@ -2069,7 +2064,6 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
getModalActive: () => overlayModalInputState.getModalInputExclusive(), getModalActive: () => overlayModalInputState.getModalInputExclusive(),
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getForceMousePassthrough: () => appState.statsOverlayVisible, getForceMousePassthrough: () => appState.statsOverlayVisible,
getOverlayInteractionActive: () => visibleOverlayInteractionActive,
getWindowTracker: () => appState.windowTracker, getWindowTracker: () => appState.windowTracker,
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName, getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
@@ -2113,24 +2107,23 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
})(), })(),
); );
const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const; const WINDOWS_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_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_FOREGROUND_POLL_INTERVAL_MS = 75;
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = []; let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
let windowsVisibleOverlayZOrderSyncInFlight = false; let windowsVisibleOverlayZOrderSyncInFlight = false;
let windowsVisibleOverlayZOrderSyncQueued = false; let windowsVisibleOverlayZOrderSyncQueued = false;
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null; let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
let lastWindowsVisibleOverlayBlurredAtMs = 0; let lastWindowsVisibleOverlayBlurredAtMs = 0;
let visibleOverlayInteractionActive = false;
function clearVisibleOverlayBlurRefreshTimeouts(): void { function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
for (const timeout of visibleOverlayBlurRefreshTimeouts) { for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
clearTimeout(timeout); clearTimeout(timeout);
} }
visibleOverlayBlurRefreshTimeouts = []; windowsVisibleOverlayBlurRefreshTimeouts = [];
} }
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void { function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
@@ -2331,22 +2324,20 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
} }
function scheduleVisibleOverlayBlurRefresh(): void { function scheduleVisibleOverlayBlurRefresh(): void {
if (process.platform !== 'win32' && process.platform !== 'darwin') { if (process.platform !== 'win32') {
return; return;
} }
if (process.platform === 'win32') {
lastWindowsVisibleOverlayBlurredAtMs = Date.now(); lastWindowsVisibleOverlayBlurredAtMs = Date.now();
} clearWindowsVisibleOverlayBlurRefreshTimeouts();
clearVisibleOverlayBlurRefreshTimeouts(); for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => { const refreshTimeout = setTimeout(() => {
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter( windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout, (timeout) => timeout !== refreshTimeout,
); );
overlayVisibilityRuntime.updateVisibleOverlayVisibility(); overlayVisibilityRuntime.updateVisibleOverlayVisibility();
}, delayMs); }, delayMs);
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout); windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
} }
} }
@@ -2911,8 +2902,6 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
}, },
isSetupCompleted: () => firstRunSetupService.isSetupCompleted(), isSetupCompleted: () => firstRunSetupService.isSetupCompleted(),
shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode, shouldQuitWhenClosedIncomplete: () => !appState.backgroundMode,
shouldQuitWhenClosedCompleted: () =>
Boolean(appState.initialArgs && isStandaloneFirstRunSetupCommand(appState.initialArgs)),
quitApp: () => requestAppQuit(), quitApp: () => requestAppQuit(),
clearSetupWindow: () => { clearSetupWindow: () => {
appState.firstRunSetupWindow = null; appState.firstRunSetupWindow = null;
@@ -3047,7 +3036,6 @@ const {
resetAnilistMediaTracking, resetAnilistMediaTracking,
getAnilistMediaGuessRuntimeState, getAnilistMediaGuessRuntimeState,
setAnilistMediaGuessRuntimeState, setAnilistMediaGuessRuntimeState,
recordAnilistMediaDuration,
resetAnilistMediaGuessState, resetAnilistMediaGuessState,
maybeProbeAnilistDuration, maybeProbeAnilistDuration,
ensureAnilistMediaGuess, ensureAnilistMediaGuess,
@@ -3151,13 +3139,6 @@ const {
); );
}, },
}, },
recordMediaDurationMainDeps: {
getCurrentMediaKey: () => getCurrentAnilistMediaKey(),
getState: () => getAnilistMediaGuessRuntimeState(),
setState: (state) => {
setAnilistMediaGuessRuntimeState(state);
},
},
resetMediaGuessStateMainDeps: { resetMediaGuessStateMainDeps: {
setMediaGuess: (value) => { setMediaGuess: (value) => {
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
@@ -3752,7 +3733,6 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
reloadConfigMainDeps: { reloadConfigMainDeps: {
reloadConfigStrict: () => configService.reloadConfigStrict(), reloadConfigStrict: () => configService.reloadConfigStrict(),
logInfo: (message) => appLogger.logInfo(message), logInfo: (message) => appLogger.logInfo(message),
logDebug: (message) => appLogger.logDebug(message),
logWarning: (message) => appLogger.logWarning(message), logWarning: (message) => appLogger.logWarning(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options), showDesktopNotification: (title, options) => showDesktopNotification(title, options),
startConfigHotReload: () => configHotReloadRuntime.start(), startConfigHotReload: () => configHotReloadRuntime.start(),
@@ -3876,9 +3856,6 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
loadYomitanExtension: async () => { loadYomitanExtension: async () => {
await loadYomitanExtension(); await loadYomitanExtension();
}, },
ensureYomitanExtensionLoaded: async () => {
await ensureYomitanExtensionLoaded();
},
handleFirstRunSetup: async () => { handleFirstRunSetup: async () => {
const snapshot = await firstRunSetupService.ensureSetupStateInitialized(); const snapshot = await firstRunSetupService.ensureSetupStateInitialized();
appState.firstRunSetupCompleted = snapshot.state.status === 'completed'; appState.firstRunSetupCompleted = snapshot.state.status === 'completed';
@@ -3996,10 +3973,7 @@ const {
reportJellyfinRemoteStopped: () => { reportJellyfinRemoteStopped: () => {
void reportJellyfinRemoteStopped(); void reportJellyfinRemoteStopped();
}, },
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options), maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(),
recordAnilistMediaDuration: (durationSec) => {
recordAnilistMediaDuration(durationSec);
},
logSubtitleTimingError: (message, error) => logger.error(message, error), logSubtitleTimingError: (message, error) => logger.error(message, error),
broadcastToOverlayWindows: (channel, payload) => { broadcastToOverlayWindows: (channel, payload) => {
broadcastToOverlayWindows(channel, payload); broadcastToOverlayWindows(channel, payload);
@@ -4639,12 +4613,12 @@ function getFetchForUpdater() {
return globalThis.fetch.bind(globalThis); return globalThis.fetch.bind(globalThis);
} }
async function updateLauncherFromSelectedRelease( async function updateLauncherFromLatestRelease(
launcherPath?: string, launcherPath?: string,
channel: UpdateChannel = getResolvedConfig().updates.channel, channel: UpdateChannel = getResolvedConfig().updates.channel,
release: GitHubRelease | null = null,
) { ) {
const fetchForUpdater = getFetchForUpdater(); const fetchForUpdater = getFetchForUpdater();
const release = await fetchLatestStableRelease({ fetch: fetchForUpdater, channel });
if (!release) { if (!release) {
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` }; return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
} }
@@ -4668,9 +4642,9 @@ async function updateLauncherFromSelectedRelease(
}); });
for (const result of supportResults) { for (const result of supportResults) {
if (result.status === 'protected' && result.command) { if (result.status === 'protected' && result.command) {
logger.warn(`Rofi theme update requires manual command: ${result.command}`); logger.warn(`Support assets update requires manual command: ${result.command}`);
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') { } else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
logger.warn(`Rofi theme update skipped: ${result.message ?? result.status}`); logger.warn(`Support assets update skipped: ${result.message ?? result.status}`);
} }
} }
return launcherResult; return launcherResult;
@@ -4683,19 +4657,6 @@ function getUpdateService() {
isPackaged: app.isPackaged, isPackaged: app.isPackaged,
log: (message) => logger.info(message), log: (message) => logger.info(message),
getChannel: () => getResolvedConfig().updates.channel, 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({ updateService = createUpdateService({
getConfig: () => getResolvedConfig().updates, getConfig: () => getResolvedConfig().updates,
@@ -4706,16 +4667,16 @@ function getUpdateService() {
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel), checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
fetchLatestStableRelease: (channel) => fetchLatestStableRelease: (channel) =>
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }), fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
updateLauncher: (launcherPath, channel, release) => updateLauncher: (launcherPath, channel) =>
updateLauncherFromSelectedRelease(launcherPath, channel, release), updateLauncherFromLatestRelease(launcherPath, channel),
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version), showNoUpdateDialog: (version) =>
showNoUpdateDialog((options) => dialog.showMessageBox(options), version),
showUpdateAvailableDialog: (version) => showUpdateAvailableDialog: (version) =>
updateDialogPresenter.showUpdateAvailableDialog(version), showUpdateAvailableDialog((options) => dialog.showMessageBox(options), version),
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message), showUpdateFailedDialog: (message) =>
showManualUpdateRequiredDialog: (version) => showUpdateFailedDialog((options) => dialog.showMessageBox(options), message),
updateDialogPresenter.showManualUpdateRequiredDialog(version),
downloadAppUpdate: () => appUpdater.downloadUpdate(), downloadAppUpdate: () => appUpdater.downloadUpdate(),
showRestartDialog: () => updateDialogPresenter.showRestartDialog(), showRestartDialog: () => showRestartDialog((options) => dialog.showMessageBox(options)),
quitAndInstall: () => appUpdater.quitAndInstall(), quitAndInstall: () => appUpdater.quitAndInstall(),
notifyUpdateAvailable: (version) => notifyUpdateAvailable: (version) =>
notifyUpdateAvailable( notifyUpdateAvailable(
@@ -5141,20 +5102,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
onOverlayModalOpened: (modal) => { onOverlayModalOpened: (modal) => {
overlayModalRuntime.notifyOverlayModalOpened(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), onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
openYomitanSettings: () => openYomitanSettings(), openYomitanSettings: () => openYomitanSettings(),
quitApp: () => requestAppQuit(), quitApp: () => requestAppQuit(),
@@ -5362,7 +5309,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
initializeOverlayRuntime: () => initializeOverlayRuntime(), initializeOverlayRuntime: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(), toggleVisibleOverlay: () => toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(), togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow(force), openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible), setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => copyCurrentSubtitle(), copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs), startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
@@ -5420,7 +5367,6 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs, getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs), schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
logInfo: (message: string) => logger.info(message), logInfo: (message: string) => logger.info(message),
logDebug: (message: string) => logger.debug(message),
logWarn: (message: string) => logger.warn(message), logWarn: (message: string) => logger.warn(message),
logError: (message: string, err: unknown) => logger.error(message, err), logError: (message: string, err: unknown) => logger.error(message, err),
}, },
-2
View File
@@ -44,7 +44,6 @@ export interface AppReadyRuntimeDepsFactoryInput {
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker']; createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession']; startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession'];
loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension']; loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension'];
ensureYomitanExtensionLoaded?: AppReadyRuntimeDeps['ensureYomitanExtensionLoaded'];
handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup']; handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup'];
prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries']; prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries'];
startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups']; startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups'];
@@ -110,7 +109,6 @@ export function createAppReadyRuntimeDeps(
createImmersionTracker: params.createImmersionTracker, createImmersionTracker: params.createImmersionTracker,
startJellyfinRemoteSession: params.startJellyfinRemoteSession, startJellyfinRemoteSession: params.startJellyfinRemoteSession,
loadYomitanExtension: params.loadYomitanExtension, loadYomitanExtension: params.loadYomitanExtension,
ensureYomitanExtensionLoaded: params.ensureYomitanExtensionLoaded,
handleFirstRunSetup: params.handleFirstRunSetup, handleFirstRunSetup: params.handleFirstRunSetup,
prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries, prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries,
startBackgroundWarmups: params.startBackgroundWarmups, startBackgroundWarmups: params.startBackgroundWarmups,
@@ -1459,7 +1459,7 @@ test('generateForCurrentMedia preserves duplicate surface forms across different
} }
}); });
test('getOrCreateCurrentSnapshot reuses cached media resolution without AniList requests', async () => { test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => {
const userDataPath = makeTempDir(); const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
let searchQueryCount = 0; let searchQueryCount = 0;
@@ -1567,18 +1567,11 @@ test('getOrCreateCurrentSnapshot reuses cached media resolution without AniList
}); });
const first = await runtime.getOrCreateCurrentSnapshot(); 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(); const second = await runtime.getOrCreateCurrentSnapshot();
assert.equal(first.fromCache, false); assert.equal(first.fromCache, false);
assert.equal(second.fromCache, true); assert.equal(second.fromCache, true);
assert.equal(searchQueryCount, 1); assert.equal(searchQueryCount, 2);
assert.equal(characterQueryCount, 1); assert.equal(characterQueryCount, 1);
assert.equal( assert.equal(
fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')), fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')),
-60
View File
@@ -15,10 +15,7 @@ import {
getMergedZipPath, getMergedZipPath,
getSnapshotPath, getSnapshotPath,
normalizeMergedMediaIds, normalizeMergedMediaIds,
readCachedMediaResolution,
readCachedSnapshots,
readSnapshot, readSnapshot,
writeCachedMediaResolution,
writeSnapshot, writeSnapshot,
} from './character-dictionary-runtime/cache'; } from './character-dictionary-runtime/cache';
import { import {
@@ -44,7 +41,6 @@ import type {
CharacterDictionaryManualSelectionResult, CharacterDictionaryManualSelectionResult,
CharacterDictionaryManualSelectionSnapshot, CharacterDictionaryManualSelectionSnapshot,
CharacterDictionaryRuntimeDeps, CharacterDictionaryRuntimeDeps,
CharacterDictionarySnapshot,
CharacterDictionarySnapshotImage, CharacterDictionarySnapshotImage,
CharacterDictionarySnapshotProgress, CharacterDictionarySnapshotProgress,
CharacterDictionarySnapshotProgressCallbacks, CharacterDictionarySnapshotProgressCallbacks,
@@ -208,26 +204,6 @@ 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 ( const resolveCurrentMedia = async (
targetPath?: string, targetPath?: string,
beforeRequest?: () => Promise<void>, beforeRequest?: () => Promise<void>,
@@ -252,43 +228,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
staleMediaIds: override.staleMediaIds, 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); 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}`); deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
return resolved; return resolved;
}; };
@@ -21,102 +21,6 @@ export function getMergedZipPath(outputDir: string): string {
return path.join(outputDir, 'merged.zip'); 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 { export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot | null {
try { try {
const raw = fs.readFileSync(snapshotPath, 'utf8'); const raw = fs.readFileSync(snapshotPath, 'utf8');
+1 -3
View File
@@ -20,7 +20,7 @@ export interface CliCommandRuntimeServiceContext {
initializeOverlay: () => void; initializeOverlay: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void; togglePrimarySubtitleBar: () => void;
openFirstRunSetup: (force?: boolean) => void; openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void; setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void; startPendingMultiCopy: (timeoutMs: number) => void;
@@ -54,7 +54,6 @@ export interface CliCommandRuntimeServiceContext {
getMultiCopyTimeoutMs: () => number; getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>; schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
log: (message: string) => void; log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void; warn: (message: string) => void;
error: (message: string, err: unknown) => void; error: (message: string, err: unknown) => void;
} }
@@ -134,7 +133,6 @@ function createCliCommandDepsFromContext(
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs, getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
schedule: context.schedule, schedule: context.schedule,
log: context.log, log: context.log,
logDebug: context.logDebug,
warn: context.warn, warn: context.warn,
error: context.error, error: context.error,
}; };
-4
View File
@@ -57,7 +57,6 @@ export interface MainIpcRuntimeServiceDepsParams {
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility']; getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed']; onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened']; onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve']; onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings']; openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp']; quitApp: IpcDepsRuntimeOptions['quitApp'];
@@ -199,7 +198,6 @@ export interface CliCommandRuntimeServiceDepsParams {
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs']; getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
schedule: CliCommandDepsRuntimeOptions['schedule']; schedule: CliCommandDepsRuntimeOptions['schedule'];
log: CliCommandDepsRuntimeOptions['log']; log: CliCommandDepsRuntimeOptions['log'];
logDebug: CliCommandDepsRuntimeOptions['logDebug'];
warn: CliCommandDepsRuntimeOptions['warn']; warn: CliCommandDepsRuntimeOptions['warn'];
error: CliCommandDepsRuntimeOptions['error']; error: CliCommandDepsRuntimeOptions['error'];
} }
@@ -230,7 +228,6 @@ export function createMainIpcRuntimeServiceDeps(
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility, getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
onOverlayModalClosed: params.onOverlayModalClosed, onOverlayModalClosed: params.onOverlayModalClosed,
onOverlayModalOpened: params.onOverlayModalOpened, onOverlayModalOpened: params.onOverlayModalOpened,
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
onYoutubePickerResolve: params.onYoutubePickerResolve, onYoutubePickerResolve: params.onYoutubePickerResolve,
openYomitanSettings: params.openYomitanSettings, openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp, quitApp: params.quitApp,
@@ -380,7 +377,6 @@ export function createCliCommandRuntimeServiceDeps(
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs, getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
schedule: params.schedule, schedule: params.schedule,
log: params.log, log: params.log,
logDebug: params.logDebug,
warn: params.warn, warn: params.warn,
error: params.error, error: params.error,
}; };
-2
View File
@@ -11,7 +11,6 @@ export interface OverlayVisibilityRuntimeDeps {
getModalActive: () => boolean; getModalActive: () => boolean;
getVisibleOverlayVisible: () => boolean; getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean; getForceMousePassthrough: () => boolean;
getOverlayInteractionActive?: () => boolean;
getWindowTracker: () => BaseWindowTracker | null; getWindowTracker: () => BaseWindowTracker | null;
getLastKnownWindowsForegroundProcessName?: () => string | null; getLastKnownWindowsForegroundProcessName?: () => string | null;
getWindowsOverlayProcessName?: () => string | null; getWindowsOverlayProcessName?: () => string | null;
@@ -50,7 +49,6 @@ export function createOverlayVisibilityRuntimeService(
visibleOverlayVisible, visibleOverlayVisible,
modalActive: deps.getModalActive(), modalActive: deps.getModalActive(),
forceMousePassthrough, forceMousePassthrough,
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
mainWindow, mainWindow,
windowTracker, windowTracker,
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(), lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
@@ -3,7 +3,6 @@ import test from 'node:test';
import { import {
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler, createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
createBuildGetCurrentAnilistMediaKeyMainDepsHandler, createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
createBuildRecordAnilistMediaDurationMainDepsHandler,
createBuildResetAnilistMediaGuessStateMainDepsHandler, createBuildResetAnilistMediaGuessStateMainDepsHandler,
createBuildResetAnilistMediaTrackingMainDepsHandler, createBuildResetAnilistMediaTrackingMainDepsHandler,
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler, createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
@@ -71,32 +70,3 @@ test('reset anilist media guess state main deps builder maps callbacks', () => {
deps.setMediaGuessPromise(null); deps.setMediaGuessPromise(null);
assert.deepEqual(calls, ['guess', 'promise']); 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,7 +1,6 @@
import type { import type {
createGetAnilistMediaGuessRuntimeStateHandler, createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler, createGetCurrentAnilistMediaKeyHandler,
createRecordAnilistMediaDurationHandler,
createResetAnilistMediaGuessStateHandler, createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler, createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler, createSetAnilistMediaGuessRuntimeStateHandler,
@@ -19,9 +18,6 @@ type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters< type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
typeof createSetAnilistMediaGuessRuntimeStateHandler typeof createSetAnilistMediaGuessRuntimeStateHandler
>[0]; >[0];
type RecordAnilistMediaDurationMainDeps = Parameters<
typeof createRecordAnilistMediaDurationHandler
>[0];
type ResetAnilistMediaGuessStateMainDeps = Parameters< type ResetAnilistMediaGuessStateMainDeps = Parameters<
typeof createResetAnilistMediaGuessStateHandler typeof createResetAnilistMediaGuessStateHandler
>[0]; >[0];
@@ -70,16 +66,6 @@ export function createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler(
}); });
} }
export function createBuildRecordAnilistMediaDurationMainDepsHandler(
deps: RecordAnilistMediaDurationMainDeps,
) {
return (): RecordAnilistMediaDurationMainDeps => ({
getCurrentMediaKey: () => deps.getCurrentMediaKey(),
getState: () => deps.getState(),
setState: (state) => deps.setState(state),
});
}
export function createBuildResetAnilistMediaGuessStateMainDepsHandler( export function createBuildResetAnilistMediaGuessStateMainDepsHandler(
deps: ResetAnilistMediaGuessStateMainDeps, deps: ResetAnilistMediaGuessStateMainDeps,
) { ) {
@@ -3,7 +3,6 @@ import test from 'node:test';
import { import {
createGetAnilistMediaGuessRuntimeStateHandler, createGetAnilistMediaGuessRuntimeStateHandler,
createGetCurrentAnilistMediaKeyHandler, createGetCurrentAnilistMediaKeyHandler,
createRecordAnilistMediaDurationHandler,
createResetAnilistMediaGuessStateHandler, createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler, createResetAnilistMediaTrackingHandler,
createSetAnilistMediaGuessRuntimeStateHandler, createSetAnilistMediaGuessRuntimeStateHandler,
@@ -177,57 +176,3 @@ test('reset anilist media guess state clears guess and in-flight promise', () =>
assert.equal(state.mediaDurationSec, 240); assert.equal(state.mediaDurationSec, 240);
assert.equal(state.lastDurationProbeAtMs, 321); 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,37 +61,6 @@ 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: { export function createResetAnilistMediaGuessStateHandler(deps: {
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void; setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void; setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
@@ -121,46 +121,6 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda
assert.ok(calls.includes('osd:updated ok')); 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 () => { test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
const calls: string[] = []; const calls: string[] = [];
let inFlight = false; let inFlight = false;
+1 -5
View File
@@ -18,7 +18,6 @@ type RetryQueueItem = {
type AnilistPostWatchRunOptions = { type AnilistPostWatchRunOptions = {
force?: boolean; force?: boolean;
watchedSeconds?: number;
}; };
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string { export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
@@ -147,10 +146,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
let watchedSeconds = 0; let watchedSeconds = 0;
if (!force) { if (!force) {
watchedSeconds = watchedSeconds = deps.getWatchedSeconds();
typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds)
? options.watchedSeconds
: deps.getWatchedSeconds();
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) { if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
return; return;
} }
@@ -36,9 +36,6 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
loadYomitanExtension: async () => { loadYomitanExtension: async () => {
calls.push('load-yomitan'); calls.push('load-yomitan');
}, },
ensureYomitanExtensionLoaded: async () => {
calls.push('ensure-yomitan');
},
handleFirstRunSetup: async () => { handleFirstRunSetup: async () => {
calls.push('handle-first-run-setup'); calls.push('handle-first-run-setup');
}, },
@@ -70,7 +67,6 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
onReady.createMpvClient(); onReady.createMpvClient();
await onReady.createMecabTokenizerAndCheck(); await onReady.createMecabTokenizerAndCheck();
await onReady.loadYomitanExtension(); await onReady.loadYomitanExtension();
await onReady.ensureYomitanExtensionLoaded?.();
await onReady.handleFirstRunSetup(); await onReady.handleFirstRunSetup();
await onReady.prewarmSubtitleDictionaries?.(); await onReady.prewarmSubtitleDictionaries?.();
onReady.startBackgroundWarmups(); onReady.startBackgroundWarmups();
@@ -83,7 +79,6 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
'create-mpv-client', 'create-mpv-client',
'create-mecab', 'create-mecab',
'load-yomitan', 'load-yomitan',
'ensure-yomitan',
'handle-first-run-setup', 'handle-first-run-setup',
'prewarm-dicts', 'prewarm-dicts',
'start-warmups', 'start-warmups',
-1
View File
@@ -27,7 +27,6 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
createImmersionTracker: deps.createImmersionTracker, createImmersionTracker: deps.createImmersionTracker,
startJellyfinRemoteSession: deps.startJellyfinRemoteSession, startJellyfinRemoteSession: deps.startJellyfinRemoteSession,
loadYomitanExtension: deps.loadYomitanExtension, loadYomitanExtension: deps.loadYomitanExtension,
ensureYomitanExtensionLoaded: deps.ensureYomitanExtensionLoaded,
handleFirstRunSetup: deps.handleFirstRunSetup, handleFirstRunSetup: deps.handleFirstRunSetup,
prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries, prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries,
startBackgroundWarmups: deps.startBackgroundWarmups, startBackgroundWarmups: deps.startBackgroundWarmups,
@@ -81,7 +81,6 @@ test('build cli command context deps maps handlers and values', () => {
return setTimeout(() => {}, 0); return setTimeout(() => {}, 0);
}, },
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message) => calls.push(`warn:${message}`), logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`), logError: (message) => calls.push(`error:${message}`),
}); });
+1 -3
View File
@@ -18,7 +18,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
initializeOverlay: () => void; initializeOverlay: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void; togglePrimarySubtitleBar: () => void;
openFirstRunSetup: (force?: boolean) => void; openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void; setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void; startPendingMultiCopy: (timeoutMs: number) => void;
@@ -52,7 +52,6 @@ export function createBuildCliCommandContextDepsHandler(deps: {
getMultiCopyTimeoutMs: () => number; getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>; schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string) => void; logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void; logError: (message: string, err: unknown) => void;
}) { }) {
@@ -107,7 +106,6 @@ export function createBuildCliCommandContextDepsHandler(deps: {
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs, getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
schedule: deps.schedule, schedule: deps.schedule,
logInfo: deps.logInfo, logInfo: deps.logInfo,
logDebug: deps.logDebug,
logWarn: deps.logWarn, logWarn: deps.logWarn,
logError: deps.logError, logError: deps.logError,
}); });
@@ -82,7 +82,6 @@ test('cli command context factory composes main deps and context handlers', () =
getMultiCopyTimeoutMs: () => 5000, getMultiCopyTimeoutMs: () => 5000,
schedule: (fn) => setTimeout(fn, 0), schedule: (fn) => setTimeout(fn, 0),
logInfo: () => {}, logInfo: () => {},
logDebug: () => {},
logWarn: () => {}, logWarn: () => {},
logError: () => {}, logError: () => {},
}); });
@@ -30,8 +30,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
initializeOverlayRuntime: () => calls.push('init-overlay'), initializeOverlayRuntime: () => calls.push('init-overlay'),
toggleVisibleOverlay: () => calls.push('toggle-visible'), toggleVisibleOverlay: () => calls.push('toggle-visible'),
togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'), togglePrimarySubtitleBar: () => calls.push('toggle-primary-subtitle'),
openFirstRunSetupWindow: (force?: boolean) => openFirstRunSetupWindow: () => calls.push('open-setup'),
calls.push(`open-setup:${force === true ? 'force' : 'default'}`),
setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`), setVisibleOverlayVisible: (visible) => calls.push(`set-visible:${visible}`),
copyCurrentSubtitle: () => calls.push('copy-sub'), copyCurrentSubtitle: () => calls.push('copy-sub'),
@@ -111,7 +110,6 @@ test('cli command context main deps builder maps state and callbacks', async ()
return setTimeout(() => {}, 0); return setTimeout(() => {}, 0);
}, },
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarn: (message) => calls.push(`warn:${message}`), logWarn: (message) => calls.push(`warn:${message}`),
logError: (message) => calls.push(`error:${message}`), logError: (message) => calls.push(`error:${message}`),
}); });
@@ -127,19 +125,11 @@ test('cli command context main deps builder maps state and callbacks', async ()
assert.equal(deps.shouldOpenBrowser(), true); assert.equal(deps.shouldOpenBrowser(), true);
deps.showOsd('hello'); deps.showOsd('hello');
deps.initializeOverlay(); deps.initializeOverlay();
deps.openFirstRunSetup(true); deps.openFirstRunSetup();
deps.setVisibleOverlay(true); deps.setVisibleOverlay(true);
deps.printHelp(); deps.printHelp();
await deps.runUpdateCommand({ update: true } as never, 'initial');
assert.deepEqual(calls, [ assert.deepEqual(calls, ['osd:hello', 'init-overlay', 'open-setup', 'set-visible:true', 'help']);
'osd:hello',
'init-overlay',
'open-setup:force',
'set-visible:true',
'help',
'run-update',
]);
const retry = await deps.retryAnilistQueueNow(); const retry = await deps.retryAnilistQueueNow();
assert.deepEqual(retry, { ok: true, message: 'ok' }); assert.deepEqual(retry, { ok: true, message: 'ok' });
@@ -28,7 +28,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlayRuntime: () => void; initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void; togglePrimarySubtitleBar: () => void;
openFirstRunSetupWindow: (force?: boolean) => void; openFirstRunSetupWindow: () => void;
setVisibleOverlayVisible: (visible: boolean) => void; setVisibleOverlayVisible: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
@@ -65,7 +65,6 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
getMultiCopyTimeoutMs: () => number; getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>; schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string) => void; logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void; logError: (message: string, err: unknown) => void;
}) { }) {
@@ -98,7 +97,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
initializeOverlay: () => deps.initializeOverlayRuntime(), initializeOverlay: () => deps.initializeOverlayRuntime(),
toggleVisibleOverlay: () => deps.toggleVisibleOverlay(), toggleVisibleOverlay: () => deps.toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(), togglePrimarySubtitleBar: () => deps.togglePrimarySubtitleBar(),
openFirstRunSetup: (force?: boolean) => deps.openFirstRunSetupWindow(force), openFirstRunSetup: () => deps.openFirstRunSetupWindow(),
setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible), setVisibleOverlay: (visible: boolean) => deps.setVisibleOverlayVisible(visible),
copyCurrentSubtitle: () => deps.copyCurrentSubtitle(), copyCurrentSubtitle: () => deps.copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs), startPendingMultiCopy: (timeoutMs: number) => deps.startPendingMultiCopy(timeoutMs),
@@ -135,7 +134,6 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(), getMultiCopyTimeoutMs: () => deps.getMultiCopyTimeoutMs(),
schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs), schedule: (fn: () => void, delayMs: number) => deps.schedule(fn, delayMs),
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
logDebug: (message: string) => deps.logDebug(message),
logWarn: (message: string) => deps.logWarn(message), logWarn: (message: string) => deps.logWarn(message),
logError: (message: string, err: unknown) => deps.logError(message, err), logError: (message: string, err: unknown) => deps.logError(message, err),
}); });
+1 -5
View File
@@ -66,9 +66,6 @@ function createDeps() {
logInfo: (message: string) => { logInfo: (message: string) => {
logs.push(`i:${message}`); logs.push(`i:${message}`);
}, },
logDebug: (message: string) => {
logs.push(`d:${message}`);
},
logWarn: (message: string) => { logWarn: (message: string) => {
logs.push(`w:${message}`); logs.push(`w:${message}`);
}, },
@@ -105,8 +102,7 @@ test('cli command context log methods map to deps loggers', () => {
const { deps, getLogs } = createDeps(); const { deps, getLogs } = createDeps();
const context = createCliCommandContext(deps); const context = createCliCommandContext(deps);
context.log('info'); context.log('info');
context.logDebug('debug');
context.warn('warn'); context.warn('warn');
context.error('error', new Error('x')); context.error('error', new Error('x'));
assert.deepEqual(getLogs(), ['i:info', 'd:debug', 'w:warn', 'e:error']); assert.deepEqual(getLogs(), ['i:info', 'w:warn', 'e:error']);
}); });
+1 -3
View File
@@ -23,7 +23,7 @@ export type CliCommandContextFactoryDeps = {
initializeOverlay: () => void; initializeOverlay: () => void;
toggleVisibleOverlay: () => void; toggleVisibleOverlay: () => void;
togglePrimarySubtitleBar: () => void; togglePrimarySubtitleBar: () => void;
openFirstRunSetup: (force?: boolean) => void; openFirstRunSetup: () => void;
setVisibleOverlay: (visible: boolean) => void; setVisibleOverlay: (visible: boolean) => void;
copyCurrentSubtitle: () => void; copyCurrentSubtitle: () => void;
startPendingMultiCopy: (timeoutMs: number) => void; startPendingMultiCopy: (timeoutMs: number) => void;
@@ -57,7 +57,6 @@ export type CliCommandContextFactoryDeps = {
getMultiCopyTimeoutMs: () => number; getMultiCopyTimeoutMs: () => number;
schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>; schedule: (fn: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarn: (message: string) => void; logWarn: (message: string) => void;
logError: (message: string, err: unknown) => void; logError: (message: string, err: unknown) => void;
}; };
@@ -134,7 +133,6 @@ export function createCliCommandContext(
getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs, getMultiCopyTimeoutMs: deps.getMultiCopyTimeoutMs,
schedule: deps.schedule, schedule: deps.schedule,
log: deps.logInfo, log: deps.logInfo,
logDebug: deps.logDebug,
warn: deps.logWarn, warn: deps.logWarn,
error: deps.logError, error: deps.logError,
}; };
@@ -110,21 +110,6 @@ 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 () => { test('resolveLauncherInstallTarget prefers writable user bin on Linux', async () => {
const target = await resolveLauncherInstallTarget({ const target = await resolveLauncherInstallTarget({
platform: 'linux', platform: 'linux',
@@ -159,53 +144,6 @@ test('resolveLauncherInstallTarget returns not_installable without writable PATH
assert.equal(target.installPath, null); 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 () => { test('installLauncher writes Windows cmd shim and appends user PATH once', async () => {
const files = new Map<string, string>(); const files = new Map<string, string>();
const dirs = new Set<string>(); const dirs = new Set<string>();
@@ -271,54 +209,6 @@ test('detectLauncher reports shadowed when another subminer appears earlier on P
assert.equal(snapshot.installPath, '/home/tester/.local/bin/subminer'); 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 () => { test('detectLauncher reports installed_bun_missing when launcher exists but bun is missing', async () => {
const snapshot = await detectLauncher({ const snapshot = await detectLauncher({
platform: 'linux', platform: 'linux',
+15 -58
View File
@@ -72,23 +72,21 @@ const BUN_OFFICIAL_WINDOWS_COMMAND = [
]; ];
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000; const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
const COMMAND_TIMEOUT_MS = 15 * 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; if (!command) return null;
const executablePath = command[0]; const executablePath = command[0];
if (!executablePath) return null; if (!executablePath) return null;
const executable = path.basename(executablePath).toLowerCase(); const executable = path.win32.basename(executablePath).toLowerCase();
const windowsExecutable = path.win32.basename(executablePath).toLowerCase(); if (executable === 'winget.exe') return 'winget';
if (windowsExecutable === 'winget.exe') return 'winget'; if (executable === 'scoop.cmd') return 'scoop';
if (windowsExecutable === 'scoop.cmd') return 'scoop'; if (executable === 'brew') return 'homebrew';
if (executable === 'brew' || windowsExecutable === 'brew') return 'homebrew';
return 'official-script'; return 'official-script';
} }
export function resolveBunInstallCommand( export function resolveBunInstallCommand(options: CommonOptions = {}): BunSnapshot['installCommand'] {
options: CommonOptions = {},
): BunSnapshot['installCommand'] {
const platform = platformOf(options); const platform = platformOf(options);
if (platform === 'win32') { if (platform === 'win32') {
const winget = findCommand('winget.exe', options); const winget = findCommand('winget.exe', options);
@@ -156,8 +154,7 @@ export async function detectBun(options: CommonOptions = {}): Promise<BunSnapsho
function resolveLauncherResourcePath(options: CommonOptions): string { function resolveLauncherResourcePath(options: CommonOptions): string {
const platformPath = pathModuleFor(platformOf(options)); const platformPath = pathModuleFor(platformOf(options));
if (options.launcherResourcePath) return options.launcherResourcePath; if (options.launcherResourcePath) return options.launcherResourcePath;
const resourcesPath = const resourcesPath = options.resourcesPath ?? (process as typeof process & { resourcesPath?: string }).resourcesPath;
options.resourcesPath ?? (process as typeof process & { resourcesPath?: string }).resourcesPath;
const packaged = resourcesPath ? platformPath.join(resourcesPath, 'launcher', 'subminer') : null; const packaged = resourcesPath ? platformPath.join(resourcesPath, 'launcher', 'subminer') : null;
if (packaged && existsSyncOf(options)(packaged)) return packaged; if (packaged && existsSyncOf(options)(packaged)) return packaged;
return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer'); return platformPath.join(options.cwd ?? process.cwd(), 'dist', 'launcher', 'subminer');
@@ -209,47 +206,11 @@ export async function resolveLauncherInstallTarget(
path.posix.join(homeDir, '.local', 'bin'), path.posix.join(homeDir, '.local', 'bin'),
path.posix.join(homeDir, 'bin'), path.posix.join(homeDir, 'bin'),
] ]
: [ : [path.posix.join(homeDir, '.local', 'bin'), path.posix.join(homeDir, 'bin'), '/usr/local/bin'];
path.posix.join(homeDir, '.local', 'bin'), const candidates = [...preferred, ...pathDirs].filter((dir, index, all) =>
path.posix.join(homeDir, 'bin'), all.findIndex((other) => normalizePathForCompare(other, platform) === normalizePathForCompare(dir, platform)) === index,
'/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) { if (!selected) {
return { return {
status: 'not_installable', status: 'not_installable',
@@ -297,14 +258,10 @@ export async function detectLauncher(
const commandPath = findCommand('subminer', options); const commandPath = findCommand('subminer', options);
const expectedNormalized = normalizePathForCompare(expectedPath, platform, platformPath); const expectedNormalized = normalizePathForCompare(expectedPath, platform, platformPath);
if ( if (commandPath && normalizePathForCompare(commandPath, platform, platformPath) !== expectedNormalized) {
commandPath &&
normalizePathForCompare(commandPath, platform, platformPath) !== expectedNormalized
) {
return { ...target, status: 'shadowed', commandPath: expectedPath, shadowedBy: commandPath }; return { ...target, status: 'shadowed', commandPath: expectedPath, shadowedBy: commandPath };
} }
if (!existsSyncOf(options)(expectedPath)) if (!existsSyncOf(options)(expectedPath)) return { ...target, status: 'not_installed', commandPath: null };
return { ...target, status: 'not_installed', commandPath: null };
if (!commandPath) { if (!commandPath) {
return { return {
...target, ...target,
@@ -80,23 +80,6 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
lastDurationProbeAtMsState = value; 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: { resetMediaGuessStateMainDeps: {
setMediaGuess: (value) => { setMediaGuess: (value) => {
mediaGuessState = value; mediaGuessState = value;
@@ -209,7 +192,6 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
assert.equal(typeof composed.resetAnilistMediaTracking, 'function'); assert.equal(typeof composed.resetAnilistMediaTracking, 'function');
assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function'); assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function');
assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function'); assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function');
assert.equal(typeof composed.recordAnilistMediaDuration, 'function');
assert.equal(typeof composed.resetAnilistMediaGuessState, 'function'); assert.equal(typeof composed.resetAnilistMediaGuessState, 'function');
assert.equal(typeof composed.maybeProbeAnilistDuration, 'function'); assert.equal(typeof composed.maybeProbeAnilistDuration, 'function');
assert.equal(typeof composed.ensureAnilistMediaGuess, 'function'); assert.equal(typeof composed.ensureAnilistMediaGuess, 'function');
@@ -234,9 +216,6 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
}); });
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90); assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90);
composed.recordAnilistMediaDuration(180);
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 180);
composed.resetAnilistMediaGuessState(); composed.resetAnilistMediaGuessState();
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null); assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null);
@@ -5,7 +5,6 @@ import {
createBuildMaybeProbeAnilistDurationMainDepsHandler, createBuildMaybeProbeAnilistDurationMainDepsHandler,
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler, createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
createBuildProcessNextAnilistRetryUpdateMainDepsHandler, createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
createBuildRecordAnilistMediaDurationMainDepsHandler,
createBuildRefreshAnilistClientSecretStateMainDepsHandler, createBuildRefreshAnilistClientSecretStateMainDepsHandler,
createBuildResetAnilistMediaGuessStateMainDepsHandler, createBuildResetAnilistMediaGuessStateMainDepsHandler,
createBuildResetAnilistMediaTrackingMainDepsHandler, createBuildResetAnilistMediaTrackingMainDepsHandler,
@@ -16,7 +15,6 @@ import {
createMaybeProbeAnilistDurationHandler, createMaybeProbeAnilistDurationHandler,
createMaybeRunAnilistPostWatchUpdateHandler, createMaybeRunAnilistPostWatchUpdateHandler,
createProcessNextAnilistRetryUpdateHandler, createProcessNextAnilistRetryUpdateHandler,
createRecordAnilistMediaDurationHandler,
createRefreshAnilistClientSecretStateHandler, createRefreshAnilistClientSecretStateHandler,
createResetAnilistMediaGuessStateHandler, createResetAnilistMediaGuessStateHandler,
createResetAnilistMediaTrackingHandler, createResetAnilistMediaTrackingHandler,
@@ -40,9 +38,6 @@ export type AnilistTrackingComposerOptions = ComposerInputs<{
setMediaGuessRuntimeStateMainDeps: Parameters< setMediaGuessRuntimeStateMainDeps: Parameters<
typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler
>[0]; >[0];
recordMediaDurationMainDeps: Parameters<
typeof createBuildRecordAnilistMediaDurationMainDepsHandler
>[0];
resetMediaGuessStateMainDeps: Parameters< resetMediaGuessStateMainDeps: Parameters<
typeof createBuildResetAnilistMediaGuessStateMainDepsHandler typeof createBuildResetAnilistMediaGuessStateMainDepsHandler
>[0]; >[0];
@@ -68,7 +63,6 @@ export type AnilistTrackingComposerResult = ComposerOutputs<{
setAnilistMediaGuessRuntimeState: ReturnType< setAnilistMediaGuessRuntimeState: ReturnType<
typeof createSetAnilistMediaGuessRuntimeStateHandler typeof createSetAnilistMediaGuessRuntimeStateHandler
>; >;
recordAnilistMediaDuration: ReturnType<typeof createRecordAnilistMediaDurationHandler>;
resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>; resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>;
maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>; maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>;
ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>; ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>;
@@ -100,9 +94,6 @@ export function composeAnilistTrackingHandlers(
options.setMediaGuessRuntimeStateMainDeps, options.setMediaGuessRuntimeStateMainDeps,
)(), )(),
); );
const recordAnilistMediaDuration = createRecordAnilistMediaDurationHandler(
createBuildRecordAnilistMediaDurationMainDepsHandler(options.recordMediaDurationMainDeps)(),
);
const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler( const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler(
createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(), createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(),
); );
@@ -129,7 +120,6 @@ export function composeAnilistTrackingHandlers(
resetAnilistMediaTracking, resetAnilistMediaTracking,
getAnilistMediaGuessRuntimeState, getAnilistMediaGuessRuntimeState,
setAnilistMediaGuessRuntimeState, setAnilistMediaGuessRuntimeState,
recordAnilistMediaDuration,
resetAnilistMediaGuessState, resetAnilistMediaGuessState,
maybeProbeAnilistDuration, maybeProbeAnilistDuration,
ensureAnilistMediaGuess, ensureAnilistMediaGuess,
@@ -11,7 +11,6 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
return { ok: true, path: '/tmp/config.jsonc', warnings: [] }; return { ok: true, path: '/tmp/config.jsonc', warnings: [] };
}, },
logInfo: () => {}, logInfo: () => {},
logDebug: () => {},
logWarning: () => {}, logWarning: () => {},
showDesktopNotification: () => {}, showDesktopNotification: () => {},
startConfigHotReload: () => {}, startConfigHotReload: () => {},
@@ -58,7 +58,6 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
getMultiCopyTimeoutMs: () => 0, getMultiCopyTimeoutMs: () => 0,
schedule: () => 0 as never, schedule: () => 0 as never,
logInfo: () => {}, logInfo: () => {},
logDebug: () => {},
logWarn: () => {}, logWarn: () => {},
logError: () => {}, logError: () => {},
}, },
@@ -82,7 +82,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
jellyfinPreviewAuth: false, jellyfinPreviewAuth: false,
texthooker: false, texthooker: false,
texthookerOpenBrowser: false, texthookerOpenBrowser: false,
update: false,
help: false, help: false,
autoStartOverlay: false, autoStartOverlay: false,
generateConfig: false, generateConfig: false,
@@ -125,7 +124,6 @@ test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
false, false,
); );
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), 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', () => { test('shouldAutoOpenFirstRunSetup treats numeric startup counts as explicit commands', () => {
@@ -119,7 +119,6 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.jellyfinRemoteAnnounce || args.jellyfinRemoteAnnounce ||
args.jellyfinPreviewAuth || args.jellyfinPreviewAuth ||
args.texthooker || args.texthooker ||
args.update ||
args.help, args.help,
); );
} }
@@ -130,10 +129,6 @@ export function shouldAutoOpenFirstRunSetup(args: CliArgs): boolean {
return !hasAnyStartupCommandBeyondSetup(args); return !hasAnyStartupCommandBeyondSetup(args);
} }
export function isStandaloneFirstRunSetupCommand(args: CliArgs): boolean {
return args.setup && !args.start && !hasAnyStartupCommandBeyondSetup(args);
}
function getPluginStatus( function getPluginStatus(
state: SetupState, state: SetupState,
pluginInstalled: boolean, pluginInstalled: boolean,
+2 -251
View File
@@ -65,9 +65,6 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
assert.match(html, /Open Yomitan Settings/); assert.match(html, /Open Yomitan Settings/);
assert.match(html, /Finish setup/); assert.match(html, /Finish setup/);
assert.match(html, /disabled/); 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', () => { test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
@@ -308,60 +305,19 @@ test('buildFirstRunSetupHtml renders command-line launcher section and actions',
assert.match(html, /Installed, Bun missing/); assert.match(html, /Installed, Bun missing/);
assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/); assert.match(html, /\/home\/tester\/\.local\/bin\/subminer/);
assert.match(html, /action=install-command-line-launcher/); assert.match(html, /action=install-command-line-launcher/);
assert.match( assert.match(html, /<button class="primary" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/);
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', () => { test('first-run setup window handler focuses existing window', () => {
const calls: string[] = []; const calls: string[] = [];
const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({ const maybeFocus = createMaybeFocusExistingFirstRunSetupWindowHandler({
getSetupWindow: () => ({ getSetupWindow: () => ({
show: () => calls.push('show'),
focus: () => calls.push('focus'), focus: () => calls.push('focus'),
}), }),
}); });
assert.equal(maybeFocus(), true); assert.equal(maybeFocus(), true);
assert.deepEqual(calls, ['show', 'focus']); assert.deepEqual(calls, ['focus']);
}); });
test('first-run setup navigation handler prevents default and dispatches supported action', async () => { test('first-run setup navigation handler prevents default and dispatches supported action', async () => {
@@ -410,138 +366,6 @@ test('first-run setup navigation handler swallows stale custom-scheme actions',
assert.deepEqual(calls, ['preventDefault']); 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 () => { test('closing incomplete first-run setup quits app outside background mode', async () => {
const calls: string[] = []; const calls: string[] = [];
let closedHandler: (() => void) | undefined; let closedHandler: (() => void) | undefined;
@@ -613,76 +437,3 @@ test('closing incomplete first-run setup quits app outside background mode', asy
assert.deepEqual(calls, ['set', 'cancelled', 'clear', 'quit']); 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']);
});
+3 -30
View File
@@ -7,7 +7,6 @@ import type {
type FocusableWindowLike = { type FocusableWindowLike = {
focus: () => void; focus: () => void;
show?: () => void;
}; };
type FirstRunSetupWebContentsLike = { type FirstRunSetupWebContentsLike = {
@@ -125,9 +124,7 @@ function getLauncherTone(
return 'muted'; return 'muted';
} }
function renderCommandLineLauncherSection( function renderCommandLineLauncherSection(commandLineLauncher: CommandLineLauncherSnapshot): string {
commandLineLauncher: CommandLineLauncherSnapshot,
): string {
if (!commandLineLauncher.supported) { if (!commandLineLauncher.supported) {
return ''; return '';
} }
@@ -157,7 +154,7 @@ function renderCommandLineLauncherSection(
bun.status === 'missing' || bun.status === 'failed' bun.status === 'missing' || bun.status === 'failed'
? `<button onclick="window.location.href='subminer://first-run-setup?action=install-bun'">Install Bun</button>` ? `<button onclick="window.location.href='subminer://first-run-setup?action=install-bun'">Install Bun</button>`
: ''; : '';
const launcherButtonDisabled = launcher.status === 'not_installable' ? 'disabled' : ''; const launcherButtonDisabled = launcher.status === 'failed' ? '' : '';
return ` return `
<section class="setup-section"> <section class="setup-section">
@@ -348,20 +345,13 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
--yellow: #eed49f; --yellow: #eed49f;
--red: #ed8796; --red: #ed8796;
} }
html,
body {
min-height: 100%;
}
body { body {
margin: 0; margin: 0;
min-height: 100vh;
background: linear-gradient(180deg, var(--mantle), var(--base)); background: linear-gradient(180deg, var(--mantle), var(--base));
color: var(--text); color: var(--text);
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
main { main {
box-sizing: border-box;
min-height: 100vh;
padding: 18px; padding: 18px;
} }
h1 { h1 {
@@ -593,7 +583,6 @@ export function createMaybeFocusExistingFirstRunSetupWindowHandler(deps: {
return (): boolean => { return (): boolean => {
const window = deps.getSetupWindow(); const window = deps.getSetupWindow();
if (!window) return false; if (!window) return false;
window.show?.();
window.focus(); window.focus();
return true; return true;
}; };
@@ -637,7 +626,6 @@ export function createOpenFirstRunSetupWindowHandler<
markSetupCancelled: () => Promise<unknown>; markSetupCancelled: () => Promise<unknown>;
isSetupCompleted: () => boolean; isSetupCompleted: () => boolean;
shouldQuitWhenClosedIncomplete: () => boolean; shouldQuitWhenClosedIncomplete: () => boolean;
shouldQuitWhenClosedCompleted?: () => boolean;
quitApp: () => void; quitApp: () => void;
clearSetupWindow: () => void; clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void; setSetupWindow: (window: TWindow) => void;
@@ -651,23 +639,11 @@ export function createOpenFirstRunSetupWindowHandler<
const setupWindow = deps.createSetupWindow(); const setupWindow = deps.createSetupWindow();
deps.setSetupWindow(setupWindow); deps.setSetupWindow(setupWindow);
setupWindow.show?.();
setupWindow.focus();
const render = async (): Promise<void> => { const render = async (): Promise<void> => {
const model = await deps.getSetupSnapshot(); const model = await deps.getSetupSnapshot();
if (setupWindow.isDestroyed()) {
return;
}
const html = deps.buildSetupHtml(model); const html = deps.buildSetupHtml(model);
if (setupWindow.isDestroyed()) {
return;
}
await setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(html)}`); await setupWindow.loadURL(`data:text/html;charset=utf-8,${deps.encodeURIComponent(html)}`);
if (!setupWindow.isDestroyed()) {
setupWindow.show?.();
setupWindow.focus();
}
}; };
const handleNavigation = createHandleFirstRunSetupNavigationHandler({ const handleNavigation = createHandleFirstRunSetupNavigationHandler({
@@ -706,10 +682,7 @@ export function createOpenFirstRunSetupWindowHandler<
}); });
} }
deps.clearSetupWindow(); deps.clearSetupWindow();
if ( if (!setupCompleted && deps.shouldQuitWhenClosedIncomplete()) {
(setupCompleted && deps.shouldQuitWhenClosedCompleted?.()) ||
(!setupCompleted && deps.shouldQuitWhenClosedIncomplete())
) {
deps.quitApp(); deps.quitApp();
} }
}); });
@@ -223,23 +223,6 @@ 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 () => { test('time-pos handler logs post-watch update rejection without blocking later handlers', async () => {
const calls: string[] = []; const calls: string[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({ const timeHandler = createHandleMpvTimePosChangeHandler({
+2 -6
View File
@@ -1,9 +1,5 @@
import type { SubtitleData } from '../../types'; import type { SubtitleData } from '../../types';
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
export function createHandleMpvSubtitleChangeHandler(deps: { export function createHandleMpvSubtitleChangeHandler(deps: {
setCurrentSubText: (text: string) => void; setCurrentSubText: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
@@ -109,7 +105,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
recordPlaybackPosition: (time: number) => void; recordPlaybackPosition: (time: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>; maybeRunAnilistPostWatchUpdate?: () => Promise<void>;
logError?: (message: string, error: unknown) => void; logError?: (message: string, error: unknown) => void;
onTimePosUpdate?: (time: number) => void; onTimePosUpdate?: (time: number) => void;
}) { }) {
@@ -117,7 +113,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
deps.recordPlaybackPosition(time); deps.recordPlaybackPosition(time);
deps.reportJellyfinRemoteProgress(false); deps.reportJellyfinRemoteProgress(false);
deps.refreshDiscordPresence(); deps.refreshDiscordPresence();
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => { void deps.maybeRunAnilistPostWatchUpdate?.().catch((error) => {
deps.logError?.('AniList post-watch update failed unexpectedly', error); deps.logError?.('AniList post-watch update failed unexpectedly', error);
}); });
deps.onTimePosUpdate?.(time); deps.onTimePosUpdate?.(time);
+2 -6
View File
@@ -18,10 +18,6 @@ import {
type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandlers>>[0]; type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandlers>>[0];
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
export function createBindMpvMainEventHandlersHandler(deps: { export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => void; reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void; syncOverlayMpvSubtitleSuppression: () => void;
@@ -38,7 +34,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void; recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
hasSubtitleTimingTracker: () => boolean; hasSubtitleTimingTracker: () => boolean;
recordSubtitleTiming: (text: string, start: number, end: number) => void; recordSubtitleTiming: (text: string, start: number, end: number) => void;
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>; maybeRunAnilistPostWatchUpdate: () => Promise<void>;
logSubtitleTimingError: (message: string, error: unknown) => void; logSubtitleTimingError: (message: string, error: unknown) => void;
setCurrentSubText: (text: string) => void; setCurrentSubText: (text: string) => void;
@@ -153,7 +149,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteProgress: (forceImmediate) => reportJellyfinRemoteProgress: (forceImmediate) =>
deps.reportJellyfinRemoteProgress(forceImmediate), deps.reportJellyfinRemoteProgress(forceImmediate),
refreshDiscordPresence: () => deps.refreshDiscordPresence(), refreshDiscordPresence: () => deps.refreshDiscordPresence(),
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options), maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
logError: (message, error) => deps.logSubtitleTimingError(message, error), logError: (message, error) => deps.logSubtitleTimingError(message, error),
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time), onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
}); });
@@ -16,7 +16,6 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`), recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`), handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`), recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`),
recordMediaDuration: (durationSec: number) => calls.push(`immersion-duration:${durationSec}`),
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`), recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
}, },
subtitleTimingTracker: { subtitleTimingTracker: {
@@ -41,7 +40,6 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
maybeRunAnilistPostWatchUpdate: async () => { maybeRunAnilistPostWatchUpdate: async () => {
calls.push('anilist-post-watch'); calls.push('anilist-post-watch');
}, },
recordAnilistMediaDuration: (durationSec) => calls.push(`anilist-duration:${durationSec}`),
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`), logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
broadcastToOverlayWindows: (channel, payload) => broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`), calls.push(`broadcast:${channel}:${String(payload)}`),
@@ -97,7 +95,6 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.resetAnilistMediaGuessState(); deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate('title'); deps.notifyImmersionTitleUpdate('title');
deps.recordPlaybackPosition(10); deps.recordPlaybackPosition(10);
deps.recordMediaDuration(1234);
deps.reportJellyfinRemoteProgress(true); deps.reportJellyfinRemoteProgress(true);
deps.onFullscreenChange?.(true); deps.onFullscreenChange?.(true);
deps.recordPauseState(true); deps.recordPauseState(true);
@@ -121,8 +118,6 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub')); assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-sidebar-layout')); 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', () => { test('mpv main event main deps wire subtitle callbacks without suppression gate', () => {
+2 -9
View File
@@ -1,9 +1,5 @@
import type { MergedToken, SubtitleData } from '../../types'; import type { MergedToken, SubtitleData } from '../../types';
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
appState: { appState: {
initialArgs?: { initialArgs?: {
@@ -46,8 +42,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
quitApp: () => void; quitApp: () => void;
reportJellyfinRemoteStopped: () => void; reportJellyfinRemoteStopped: () => void;
syncOverlayMpvSubtitleSuppression: () => void; syncOverlayMpvSubtitleSuppression: () => void;
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>; maybeRunAnilistPostWatchUpdate: () => Promise<void>;
recordAnilistMediaDuration?: (durationSec: number) => void;
logSubtitleTimingError: (message: string, error: unknown) => void; logSubtitleTimingError: (message: string, error: unknown) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
@@ -131,8 +126,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker), hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) => recordSubtitleTiming: (text: string, start: number, end: number) =>
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end), deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
deps.maybeRunAnilistPostWatchUpdate(options),
logSubtitleTimingError: (message: string, error: unknown) => logSubtitleTimingError: (message: string, error: unknown) =>
deps.logSubtitleTimingError(message, error), deps.logSubtitleTimingError(message, error),
setCurrentSubText: (text: string) => { setCurrentSubText: (text: string) => {
@@ -185,7 +179,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordMediaDuration: (durationSec: number) => { recordMediaDuration: (durationSec: number) => {
deps.ensureImmersionTrackerInitialized(); deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordMediaDuration?.(durationSec); deps.appState.immersionTracker?.recordMediaDuration?.(durationSec);
deps.recordAnilistMediaDuration?.(durationSec);
}, },
reportJellyfinRemoteProgress: (forceImmediate: boolean) => reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate), deps.reportJellyfinRemoteProgress(forceImmediate),
@@ -15,7 +15,6 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
getModalActive: () => true, getModalActive: () => true,
getVisibleOverlayVisible: () => true, getVisibleOverlayVisible: () => true,
getForceMousePassthrough: () => true, getForceMousePassthrough: () => true,
getOverlayInteractionActive: () => true,
getWindowTracker: () => tracker, getWindowTracker: () => tracker,
getLastKnownWindowsForegroundProcessName: () => 'mpv', getLastKnownWindowsForegroundProcessName: () => 'mpv',
getWindowsOverlayProcessName: () => 'subminer', getWindowsOverlayProcessName: () => 'subminer',
@@ -41,7 +40,6 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
assert.equal(deps.getModalActive(), true); assert.equal(deps.getModalActive(), true);
assert.equal(deps.getVisibleOverlayVisible(), true); assert.equal(deps.getVisibleOverlayVisible(), true);
assert.equal(deps.getForceMousePassthrough(), true); assert.equal(deps.getForceMousePassthrough(), true);
assert.equal(deps.getOverlayInteractionActive?.(), true);
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv'); assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer'); assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true); assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
@@ -10,7 +10,6 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
getModalActive: () => deps.getModalActive(), getModalActive: () => deps.getModalActive(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getForceMousePassthrough: () => deps.getForceMousePassthrough(), getForceMousePassthrough: () => deps.getForceMousePassthrough(),
getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false,
getWindowTracker: () => deps.getWindowTracker(), getWindowTracker: () => deps.getWindowTracker(),
getLastKnownWindowsForegroundProcessName: () => getLastKnownWindowsForegroundProcessName: () =>
deps.getLastKnownWindowsForegroundProcessName?.() ?? null, deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
@@ -17,8 +17,8 @@ test('createCreateFirstRunSetupWindowHandler builds first-run setup window', ()
assert.deepEqual(createSetupWindow(), { id: 'first-run' }); assert.deepEqual(createSetupWindow(), { id: 'first-run' });
assert.deepEqual(options, { assert.deepEqual(options, {
width: 720, width: 560,
height: 860, height: 640,
title: 'SubMiner Setup', title: 'SubMiner Setup',
show: true, show: true,
autoHideMenuBar: true, autoHideMenuBar: true,
+2 -2
View File
@@ -32,8 +32,8 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) { }) {
return createSetupWindowHandler(deps, { return createSetupWindowHandler(deps, {
width: 720, width: 560,
height: 860, height: 640,
title: 'SubMiner Setup', title: 'SubMiner Setup',
resizable: false, resizable: false,
minimizable: false, minimizable: false,
@@ -10,7 +10,6 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
const deps = createBuildReloadConfigMainDepsHandler({ const deps = createBuildReloadConfigMainDepsHandler({
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }), reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarning: (message) => calls.push(`warn:${message}`), logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('start-hot-reload'), startConfigHotReload: () => calls.push('start-hot-reload'),
@@ -31,7 +30,6 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
warnings: [], warnings: [],
}); });
deps.logInfo('x'); deps.logInfo('x');
deps.logDebug('debug');
deps.logWarning('y'); deps.logWarning('y');
deps.showDesktopNotification('SubMiner', { body: 'warn' }); deps.showDesktopNotification('SubMiner', { body: 'warn' });
deps.startConfigHotReload(); deps.startConfigHotReload();
@@ -41,7 +39,6 @@ test('reload config main deps builder maps callbacks and fail handlers', async (
deps.failHandlers.quit(); deps.failHandlers.quit();
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'info:x', 'info:x',
'debug:debug',
'warn:y', 'warn:y',
'notify:SubMiner:warn', 'notify:SubMiner:warn',
'start-hot-reload', 'start-hot-reload',
@@ -7,7 +7,6 @@ export function createBuildReloadConfigMainDepsHandler(deps: ReloadConfigMainDep
return (): ReloadConfigMainDeps => ({ return (): ReloadConfigMainDeps => ({
reloadConfigStrict: () => deps.reloadConfigStrict(), reloadConfigStrict: () => deps.reloadConfigStrict(),
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
logDebug: (message: string) => deps.logDebug(message),
logWarning: (message: string) => deps.logWarning(message), logWarning: (message: string) => deps.logWarning(message),
showDesktopNotification: (title: string, options: { body: string }) => showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options), deps.showDesktopNotification(title, options),
+1 -8
View File
@@ -20,7 +20,6 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
], ],
}), }),
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarning: (message) => calls.push(`warn:${message}`), logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('hotReload:start'), startConfigHotReload: () => calls.push('hotReload:start'),
@@ -37,11 +36,7 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
reloadConfig(); reloadConfig();
await Promise.resolve(); await Promise.resolve();
assert.ok(calls.some((entry) => entry.startsWith('debug:Using config file: /tmp/config.jsonc'))); assert.ok(calls.some((entry) => entry.startsWith('info: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.startsWith('warn:[config] Validation found 1 issue(s)')));
assert.ok( assert.ok(
calls.some((entry) => entry.includes('notify:SubMiner:1 config validation issue(s) detected.')), calls.some((entry) => entry.includes('notify:SubMiner:1 config validation issue(s) detected.')),
@@ -69,7 +64,6 @@ test('createReloadConfigHandler fails startup for parse errors', () => {
error: 'unexpected token', error: 'unexpected token',
}), }),
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarning: (message) => calls.push(`warn:${message}`), logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('hotReload:start'), startConfigHotReload: () => calls.push('hotReload:start'),
@@ -108,7 +102,6 @@ test('createReloadConfigHandler can skip AniList refresh for headless commands',
warnings: [], warnings: [],
}), }),
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
logDebug: (message) => calls.push(`debug:${message}`),
logWarning: (message) => calls.push(`warn:${message}`), logWarning: (message) => calls.push(`warn:${message}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`), showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
startConfigHotReload: () => calls.push('hotReload:start'), startConfigHotReload: () => calls.push('hotReload:start'),
+1 -2
View File
@@ -24,7 +24,6 @@ type ReloadConfigStrictResult = ReloadConfigFailure | ReloadConfigSuccess;
export type ReloadConfigRuntimeDeps = { export type ReloadConfigRuntimeDeps = {
reloadConfigStrict: () => ReloadConfigStrictResult; reloadConfigStrict: () => ReloadConfigStrictResult;
logInfo: (message: string) => void; logInfo: (message: string) => void;
logDebug: (message: string) => void;
logWarning: (message: string) => void; logWarning: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void; showDesktopNotification: (title: string, options: { body: string }) => void;
startConfigHotReload: () => void; startConfigHotReload: () => void;
@@ -62,7 +61,7 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () =>
); );
} }
deps.logDebug(`Using config file: ${result.path}`); deps.logInfo(`Using config file: ${result.path}`);
if (result.warnings.length > 0) { if (result.warnings.length > 0) {
deps.logWarning(buildConfigWarningSummary(result.path, result.warnings)); deps.logWarning(buildConfigWarningSummary(result.path, result.warnings));
deps.showDesktopNotification('SubMiner', { deps.showDesktopNotification('SubMiner', {
+2 -235
View File
@@ -1,13 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import { configureAutoUpdater, type ElectronAutoUpdaterLike } from './app-updater';
configureAutoUpdater,
createElectronAppUpdater,
isKnownLinuxPackageManagedAppImage,
isNativeUpdaterSupported,
resolveMacAppBundlePath,
type ElectronAutoUpdaterLike,
} from './app-updater';
type UpdaterLogger = { type UpdaterLogger = {
info: (message: string) => void; info: (message: string) => void;
@@ -18,12 +11,8 @@ type UpdaterLogger = {
test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => { test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => {
const logged: string[] = []; const logged: string[] = [];
const updater: ElectronAutoUpdaterLike & { const updater: ElectronAutoUpdaterLike & { logger?: UpdaterLogger | null } = {
autoInstallOnAppQuit: boolean;
logger?: UpdaterLogger | null;
} = {
autoDownload: true, autoDownload: true,
autoInstallOnAppQuit: true,
allowPrerelease: true, allowPrerelease: true,
allowDowngrade: true, allowDowngrade: true,
logger: null, logger: null,
@@ -35,7 +24,6 @@ test('configureAutoUpdater disables eager update behavior and suppresses info lo
configureAutoUpdater(updater, (message) => logged.push(message)); configureAutoUpdater(updater, (message) => logged.push(message));
assert.equal(updater.autoDownload, false); assert.equal(updater.autoDownload, false);
assert.equal(updater.autoInstallOnAppQuit, false);
assert.equal(updater.allowPrerelease, false); assert.equal(updater.allowPrerelease, false);
assert.equal(updater.allowDowngrade, false); assert.equal(updater.allowDowngrade, false);
assert.ok(updater.logger); assert.ok(updater.logger);
@@ -65,224 +53,3 @@ test('configureAutoUpdater allows prereleases only for the prerelease channel',
configureAutoUpdater(updater, () => {}, 'stable'); configureAutoUpdater(updater, () => {}, 'stable');
assert.equal(updater.allowPrerelease, false); assert.equal(updater.allowPrerelease, false);
}); });
test('configureAutoUpdater handles late updater error events', () => {
const logged: string[] = [];
const errorListeners: Array<(error: unknown) => void> = [];
const updater: ElectronAutoUpdaterLike & {
on: (event: string, listener: (error: unknown) => void) => typeof updater;
} = {
autoDownload: true,
allowPrerelease: false,
allowDowngrade: true,
logger: null,
checkForUpdates: async () => null,
downloadUpdate: async () => [],
quitAndInstall: () => {},
on: (event, listener) => {
if (event === 'error') errorListeners.push(listener);
return updater;
},
};
configureAutoUpdater(updater, (message) => logged.push(message));
const [errorListener] = errorListeners;
assert.ok(errorListener);
errorListener(new Error('APPIMAGE env is not defined'));
assert.deepEqual(logged, ['Updater error event: APPIMAGE env is not defined']);
});
test('app updater skips native update checks when native updater is unsupported', async () => {
let checked = false;
const updater: ElectronAutoUpdaterLike = {
autoDownload: true,
allowPrerelease: false,
allowDowngrade: true,
logger: null,
checkForUpdates: async () => {
checked = true;
return {
updateInfo: {
version: '0.15.0',
},
};
},
downloadUpdate: async () => [],
quitAndInstall: () => {},
};
const logged: string[] = [];
const appUpdater = createElectronAppUpdater({
currentVersion: '0.14.0',
isPackaged: true,
updater,
log: (message) => logged.push(message),
isNativeUpdaterSupported: () => false,
});
const result = await appUpdater.checkForUpdates('stable');
assert.equal(checked, false);
assert.deepEqual(result, {
available: false,
version: '0.14.0',
canUpdate: false,
});
assert.deepEqual(logged, [
'Skipping native app update check because native updater is unsupported.',
]);
});
test('app updater skips native downloads when native updater is unsupported', async () => {
let downloaded = false;
const updater: ElectronAutoUpdaterLike = {
autoDownload: true,
allowPrerelease: false,
allowDowngrade: true,
logger: null,
checkForUpdates: async () => null,
downloadUpdate: async () => {
downloaded = true;
return [];
},
quitAndInstall: () => {},
};
const logged: string[] = [];
const appUpdater = createElectronAppUpdater({
currentVersion: '0.14.0',
isPackaged: true,
updater,
log: (message) => logged.push(message),
isNativeUpdaterSupported: () => false,
});
await appUpdater.downloadUpdate();
assert.equal(downloaded, false);
assert.deepEqual(logged, ['Skipping app update download because native updater is unsupported.']);
});
test('resolveMacAppBundlePath resolves packaged macOS executable path', () => {
assert.equal(
resolveMacAppBundlePath('/Applications/SubMiner.app/Contents/MacOS/SubMiner'),
'/Applications/SubMiner.app',
);
assert.equal(resolveMacAppBundlePath('/usr/local/bin/SubMiner'), null);
});
test('mac native updater is unsupported for ad-hoc signed app bundles', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'darwin',
isPackaged: true,
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
readCodeSignature: () =>
['Signature=adhoc', 'TeamIdentifier=not set', 'Runtime Version=26.0.0'].join('\n'),
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
});
test('mac native updater supports Developer ID signed packaged app bundles', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'darwin',
isPackaged: true,
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
log: (message) => logged.push(message),
readCodeSignature: async () => 'Authority=Developer ID Application: Kyle Yasuda (EPJ9P4RWTC)',
});
assert.equal(supported, true);
assert.deepEqual(logged, []);
});
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'linux',
isPackaged: true,
execPath: '/tmp/.mount_SubMiner/SubMiner',
env: {
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
},
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
]);
});
test('linux native updater is unsupported when APPIMAGE is missing', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'linux',
isPackaged: true,
execPath: '/tmp/.mount_SubMiner/SubMiner',
env: {},
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
]);
});
test('linux native updater is unsupported for non-writable AppImage installs', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'linux',
isPackaged: true,
execPath: '/tmp/.mount_SubMiner/SubMiner',
env: {
APPIMAGE: '/home/tester/.local/bin/SubMiner.AppImage',
},
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
]);
});
test('linux native updater is unsupported for package-managed AppImage installs', async () => {
const logged: string[] = [];
const supported = await isNativeUpdaterSupported({
platform: 'linux',
isPackaged: true,
execPath: '/tmp/.mount_SubMiner/SubMiner',
env: {
APPIMAGE: '/opt/SubMiner/SubMiner.AppImage',
},
log: (message) => logged.push(message),
});
assert.equal(supported, false);
assert.deepEqual(logged, [
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
]);
});
test('known Linux package-managed AppImage detection follows the canonical AUR path', () => {
assert.equal(isKnownLinuxPackageManagedAppImage('/opt/SubMiner/SubMiner.AppImage'), true);
assert.equal(
isKnownLinuxPackageManagedAppImage('/home/tester/.local/bin/SubMiner.AppImage'),
false,
);
});
test('native updater is unsupported on Windows by default', async () => {
const supported = await isNativeUpdaterSupported({
platform: 'win32',
isPackaged: true,
execPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
});
assert.equal(supported, false);
});
+1 -135
View File
@@ -1,6 +1,3 @@
import { realpathSync } from 'node:fs';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { autoUpdater as electronAutoUpdater } from 'electron-updater'; import { autoUpdater as electronAutoUpdater } from 'electron-updater';
import type { UpdateChannel } from '../../../types/config'; import type { UpdateChannel } from '../../../types/config';
import { compareSemverLike } from './release-assets'; import { compareSemverLike } from './release-assets';
@@ -20,13 +17,9 @@ export interface ElectronUpdaterLoggerLike {
export interface ElectronAutoUpdaterLike { export interface ElectronAutoUpdaterLike {
autoDownload: boolean; autoDownload: boolean;
autoInstallOnAppQuit?: boolean;
allowPrerelease: boolean; allowPrerelease: boolean;
allowDowngrade: boolean; allowDowngrade: boolean;
logger?: ElectronUpdaterLoggerLike | null; logger?: ElectronUpdaterLoggerLike | null;
on?: (event: 'error', listener: (error: unknown) => void) => unknown;
off?: (event: 'error', listener: (error: unknown) => void) => unknown;
removeListener?: (event: 'error', listener: (error: unknown) => void) => unknown;
checkForUpdates: () => Promise<{ checkForUpdates: () => Promise<{
updateInfo?: { updateInfo?: {
version?: string; version?: string;
@@ -36,93 +29,12 @@ export interface ElectronAutoUpdaterLike {
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void; quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void;
} }
const updaterErrorListeners = new WeakMap<object, (error: unknown) => void>();
const execFileAsync = promisify(execFile);
export function resolveMacAppBundlePath(execPath: string): string | null {
const marker = '.app/Contents/MacOS/';
const markerIndex = execPath.indexOf(marker);
if (markerIndex < 0) return null;
return execPath.slice(0, markerIndex + '.app'.length);
}
async function readMacCodeSignature(appBundlePath: string): Promise<string | null> {
try {
const result = await execFileAsync('/usr/bin/codesign', ['-dv', '--verbose=4', appBundlePath], {
encoding: 'utf8',
});
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
} catch {
return null;
}
}
function realpathOrOriginal(filePath: string): string {
try {
return realpathSync(filePath);
} catch {
return filePath;
}
}
export function isKnownLinuxPackageManagedAppImage(appImagePath: string): boolean {
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
}
export async function isNativeUpdaterSupported(options: {
platform: NodeJS.Platform;
isPackaged: boolean;
execPath: string;
env?: NodeJS.ProcessEnv;
readCodeSignature?: (appBundlePath: string) => string | null | Promise<string | null>;
log?: (message: string) => void;
}): Promise<boolean> {
if (!options.isPackaged) {
options.log?.('Skipping native updater because this build is not packaged.');
return false;
}
if (options.platform === 'linux') {
options.log?.(
'Skipping native Linux updater because Linux tray checks use GitHub release assets.',
);
return false;
}
if (options.platform !== 'darwin') {
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
return false;
}
const appBundlePath = resolveMacAppBundlePath(options.execPath);
if (!appBundlePath) {
options.log?.(
'Skipping native macOS updater because the app bundle path could not be resolved.',
);
return false;
}
const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
if (!signature) {
options.log?.(
'Skipping native macOS updater because the app code signature could not be read.',
);
return false;
}
if (/Signature=adhoc\b/.test(signature) || /TeamIdentifier=not set\b/.test(signature)) {
options.log?.('Skipping native macOS updater because this build is ad-hoc signed.');
return false;
}
return true;
}
export function configureAutoUpdater( export function configureAutoUpdater(
updater: ElectronAutoUpdaterLike, updater: ElectronAutoUpdaterLike,
log: (message: string) => void = () => {}, log: (message: string) => void = () => {},
channel: UpdateChannel = 'stable', channel: UpdateChannel = 'stable',
): ElectronAutoUpdaterLike { ): ElectronAutoUpdaterLike {
updater.autoDownload = false; updater.autoDownload = false;
// On macOS this avoids invoking Squirrel until the explicit restart/install step.
updater.autoInstallOnAppQuit = false;
updater.allowPrerelease = channel === 'prerelease'; updater.allowPrerelease = channel === 'prerelease';
updater.allowDowngrade = false; updater.allowDowngrade = false;
updater.logger = { updater.logger = {
@@ -131,22 +43,6 @@ export function configureAutoUpdater(
warn: (message) => log(message), warn: (message) => log(message),
error: (message) => log(message), error: (message) => log(message),
}; };
const previousErrorListener = updaterErrorListeners.get(updater);
if (previousErrorListener) {
if (updater.off) {
updater.off('error', previousErrorListener);
} else {
updater.removeListener?.('error', previousErrorListener);
}
}
if (updater.on) {
const errorListener = (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
log(`Updater error event: ${message}`);
};
updater.on('error', errorListener);
updaterErrorListeners.set(updater, errorListener);
}
return updater; return updater;
} }
@@ -156,7 +52,6 @@ export function createElectronAppUpdater(options: {
updater?: ElectronAutoUpdaterLike; updater?: ElectronAutoUpdaterLike;
log: (message: string) => void; log: (message: string) => void;
getChannel?: () => UpdateChannel; getChannel?: () => UpdateChannel;
isNativeUpdaterSupported?: () => boolean | Promise<boolean>;
}) { }) {
const getChannel = options.getChannel ?? (() => 'stable' as const); const getChannel = options.getChannel ?? (() => 'stable' as const);
const updater = configureAutoUpdater( const updater = configureAutoUpdater(
@@ -164,15 +59,6 @@ export function createElectronAppUpdater(options: {
options.log, options.log,
getChannel(), getChannel(),
); );
let nativeUpdaterSupported: Promise<boolean> | null = null;
async function getNativeUpdaterSupported(): Promise<boolean> {
if (!options.isNativeUpdaterSupported) return true;
if (nativeUpdaterSupported === null) {
nativeUpdaterSupported = Promise.resolve(options.isNativeUpdaterSupported());
}
return nativeUpdaterSupported;
}
return { return {
async checkForUpdates(channel?: UpdateChannel): Promise<AppUpdateCheckResult> { async checkForUpdates(channel?: UpdateChannel): Promise<AppUpdateCheckResult> {
@@ -183,14 +69,6 @@ export function createElectronAppUpdater(options: {
canUpdate: false, canUpdate: false,
}; };
} }
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping native app update check because native updater is unsupported.');
return {
available: false,
version: options.currentVersion,
canUpdate: false,
};
}
configureAutoUpdater(updater, options.log, channel ?? getChannel()); configureAutoUpdater(updater, options.log, channel ?? getChannel());
const result = await updater.checkForUpdates(); const result = await updater.checkForUpdates();
const version = result?.updateInfo?.version ?? options.currentVersion; const version = result?.updateInfo?.version ?? options.currentVersion;
@@ -205,21 +83,9 @@ export function createElectronAppUpdater(options: {
options.log('Skipping app update download because this build is not packaged.'); options.log('Skipping app update download because this build is not packaged.');
return; return;
} }
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping app update download because native updater is unsupported.');
return;
}
await updater.downloadUpdate(); await updater.downloadUpdate();
}, },
async quitAndInstall(): Promise<void> { quitAndInstall(): void {
if (!options.isPackaged) {
options.log('Skipping app update install because this build is not packaged.');
return;
}
if (!(await getNativeUpdaterSupported())) {
options.log('Skipping app update install because native updater is unsupported.');
return;
}
updater.quitAndInstall(false, true); updater.quitAndInstall(false, true);
}, },
}; };
@@ -1,140 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { buildProtectedAppImageUpdateCommand, updateAppImageFromRelease } from './appimage-updater';
const appImageBytes = Buffer.from('appimage');
const appImageHash = createHash('sha256').update(appImageBytes).digest('hex');
test('updateAppImageFromRelease verifies hash and atomically replaces writable AppImage', async () => {
const writes: Array<{ path: string; data: Buffer }> = [];
const chmods: Array<{ path: string; mode: number }> = [];
const renames: Array<{ from: string; to: string }> = [];
const result = await updateAppImageFromRelease({
release: {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
},
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
appImagePath: '/home/kyle/.local/bin/SubMiner.AppImage',
downloadAsset: async () => appImageBytes,
fs: {
stat: async () => ({
isFile: () => true,
mode: 0o755,
}),
access: async () => {},
writeFile: async (targetPath, data) => {
writes.push({ path: targetPath, data });
},
chmod: async (targetPath, mode) => {
chmods.push({ path: targetPath, mode });
},
rename: async (from, to) => {
renames.push({ from, to });
},
unlink: async () => {},
},
});
assert.deepEqual(result, {
status: 'updated',
path: '/home/kyle/.local/bin/SubMiner.AppImage',
});
assert.deepEqual(writes, [
{ path: '/home/kyle/.local/bin/.SubMiner.AppImage.update', data: appImageBytes },
]);
assert.deepEqual(chmods, [
{ path: '/home/kyle/.local/bin/.SubMiner.AppImage.update', mode: 0o755 },
]);
assert.deepEqual(renames, [
{
from: '/home/kyle/.local/bin/.SubMiner.AppImage.update',
to: '/home/kyle/.local/bin/SubMiner.AppImage',
},
]);
});
test('updateAppImageFromRelease reports protected command without replacing non-writable AppImage', async () => {
const result = await updateAppImageFromRelease({
release: {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
},
sha256Sums: new Map([['SubMiner.AppImage', appImageHash]]),
appImagePath: '/opt/SubMiner/SubMiner.AppImage',
downloadAsset: async () => appImageBytes,
fs: {
stat: async () => ({
isFile: () => true,
mode: 0o755,
}),
access: async () => {
throw new Error('EACCES');
},
writeFile: async () => {
throw new Error('unexpected write');
},
chmod: async () => {},
rename: async () => {},
unlink: async () => {},
},
});
assert.equal(result.status, 'protected');
assert.equal(result.path, '/opt/SubMiner/SubMiner.AppImage');
assert.match(result.command ?? '', /curl -fSL 'https:\/\/example\.test\/app' -o "\$tmp"/);
assert.match(result.command ?? '', /sha256sum -c -/);
assert.match(result.command ?? '', /sudo mv "\$tmp" '\/opt\/SubMiner\/SubMiner\.AppImage'/);
});
test('buildProtectedAppImageUpdateCommand quotes inputs and verifies checksum before sudo move', () => {
const command = buildProtectedAppImageUpdateCommand(
"https://example.test/Sub Miner.AppImage?sig='abc'",
"/opt/Sub Miner/SubMiner's.AppImage",
'ABCDEF',
);
assert.match(command, /trap 'rm -f "\$tmp"' EXIT/);
assert.match(
command,
/curl -fSL 'https:\/\/example\.test\/Sub Miner\.AppImage\?sig='\\''abc'\\''' -o "\$tmp"/,
);
assert.match(command, /printf '%s %s\\n' 'abcdef' "\$tmp" \| sha256sum -c -/);
assert.match(command, /sudo mv "\$tmp" '\/opt\/Sub Miner\/SubMiner'\\''s\.AppImage'/);
assert.match(command, /sudo chmod \+x '\/opt\/Sub Miner\/SubMiner'\\''s\.AppImage'/);
});
test('updateAppImageFromRelease aborts on hash mismatch', async () => {
const result = await updateAppImageFromRelease({
release: {
tag_name: 'v0.15.0',
prerelease: false,
draft: false,
assets: [{ name: 'SubMiner.AppImage', browser_download_url: 'https://example.test/app' }],
},
sha256Sums: new Map([['SubMiner.AppImage', '0'.repeat(64)]]),
appImagePath: '/home/kyle/.local/bin/SubMiner.AppImage',
downloadAsset: async () => appImageBytes,
fs: {
stat: async () => ({
isFile: () => true,
mode: 0o755,
}),
access: async () => {},
writeFile: async () => {
throw new Error('unexpected write');
},
chmod: async () => {},
rename: async () => {},
unlink: async () => {},
},
});
assert.equal(result.status, 'hash-mismatch');
});
-155
View File
@@ -1,155 +0,0 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { GitHubRelease } from './release-assets';
import { findReleaseAsset } from './release-assets';
type StatLike = {
isFile: () => boolean;
mode?: number;
};
export type AppImageUpdateStatus =
| 'updated'
| 'skipped'
| 'protected'
| 'hash-mismatch'
| 'not-found'
| 'missing-asset';
export interface AppImageUpdateResult {
status: AppImageUpdateStatus;
path?: string;
command?: string;
message?: string;
}
export interface AppImageUpdateFileSystem {
stat: (targetPath: string) => Promise<StatLike>;
access: (targetPath: string) => Promise<void>;
writeFile: (targetPath: string, data: Buffer) => Promise<void>;
chmod: (targetPath: string, mode: number) => Promise<void>;
rename: (fromPath: string, toPath: string) => Promise<void>;
unlink: (targetPath: string) => Promise<void>;
}
function sha256(data: Buffer): string {
return createHash('sha256').update(data).digest('hex');
}
function defaultFs(): AppImageUpdateFileSystem {
return {
stat: (targetPath) => fs.promises.stat(targetPath),
access: async (targetPath) => {
await fs.promises.access(targetPath, fs.constants.W_OK);
},
writeFile: (targetPath, data) => fs.promises.writeFile(targetPath, data),
chmod: (targetPath, mode) => fs.promises.chmod(targetPath, mode),
rename: (fromPath, toPath) => fs.promises.rename(fromPath, toPath),
unlink: async (targetPath) => {
await fs.promises.unlink(targetPath).catch(() => undefined);
},
};
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function buildProtectedAppImageUpdateCommand(
assetUrl: string,
appImagePath: string,
expectedSha256: string,
): string {
const quotedUrl = shellQuote(assetUrl);
const quotedPath = shellQuote(appImagePath);
const quotedSha256 = shellQuote(expectedSha256.toLowerCase());
return [
'tmp=$(mktemp)',
'trap \'rm -f "$tmp"\' EXIT',
`curl -fSL ${quotedUrl} -o "$tmp"`,
`printf '%s %s\\n' ${quotedSha256} "$tmp" | sha256sum -c -`,
`sudo mv "$tmp" ${quotedPath}`,
`sudo chmod +x ${quotedPath}`,
].join(' && ');
}
function selectAppImageAsset(release: GitHubRelease, appImagePath: string) {
const basename = path.basename(appImagePath);
return (
findReleaseAsset(release, basename) ??
findReleaseAsset(release, 'SubMiner.AppImage') ??
release.assets.find((asset) => asset.name.endsWith('.AppImage')) ??
null
);
}
export async function updateAppImageFromRelease(options: {
release: GitHubRelease | null;
sha256Sums: Map<string, string>;
appImagePath?: string;
downloadAsset: (url: string) => Promise<Buffer>;
fs?: AppImageUpdateFileSystem;
}): Promise<AppImageUpdateResult> {
if (!options.appImagePath) {
return { status: 'not-found', message: 'No AppImage path detected.' };
}
if (!options.release) return { status: 'missing-asset', message: 'No release found.' };
const asset = selectAppImageAsset(options.release, options.appImagePath);
if (!asset) return { status: 'missing-asset', message: 'Release has no AppImage asset.' };
const expectedSha256 = options.sha256Sums.get(asset.name);
if (!expectedSha256) {
return { status: 'missing-asset', message: `SHA256SUMS.txt has no ${asset.name} entry.` };
}
const fsDeps = options.fs ?? defaultFs();
let stat: StatLike;
try {
stat = await fsDeps.stat(options.appImagePath);
} catch {
return { status: 'not-found', path: options.appImagePath };
}
if (!stat.isFile()) {
return { status: 'skipped', path: options.appImagePath, message: 'AppImage is not a file.' };
}
try {
await fsDeps.access(options.appImagePath);
} catch {
return {
status: 'protected',
path: options.appImagePath,
command: buildProtectedAppImageUpdateCommand(
asset.browser_download_url,
options.appImagePath,
expectedSha256,
),
};
}
const data = await options.downloadAsset(asset.browser_download_url);
const actualSha256 = sha256(data);
if (actualSha256 !== expectedSha256.toLowerCase()) {
return {
status: 'hash-mismatch',
path: options.appImagePath,
message: `Expected ${expectedSha256}, got ${actualSha256}.`,
};
}
const tempPath = path.join(
path.dirname(options.appImagePath),
`.${path.basename(options.appImagePath)}.update`,
);
try {
await fsDeps.writeFile(tempPath, data);
await fsDeps.chmod(tempPath, stat.mode ? stat.mode & 0o777 : 0o755);
await fsDeps.rename(tempPath, options.appImagePath);
return { status: 'updated', path: options.appImagePath };
} catch (error) {
await fsDeps.unlink(tempPath);
throw error;
}
}
@@ -15,13 +15,13 @@ test('looksLikeSubminerLauncher rejects unrelated executable content', () => {
assert.equal(looksLikeSubminerLauncher(Buffer.from('SubMiner launcher binary payload')), true); assert.equal(looksLikeSubminerLauncher(Buffer.from('SubMiner launcher binary payload')), true);
}); });
test('buildProtectedLauncherUpdateCommand quotes sudo curl and chmod paths', () => { test('buildProtectedLauncherUpdateCommand uses sudo curl and chmod for protected paths', () => {
assert.equal( assert.equal(
buildProtectedLauncherUpdateCommand( buildProtectedLauncherUpdateCommand(
"https://github.com/ksyasuda/SubMiner/releases/latest/download/sub miner?sig='abc'", 'https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer',
"/usr/local/bin/subminer's launcher", '/usr/local/bin/subminer',
), ),
"sudo curl -fSL 'https://github.com/ksyasuda/SubMiner/releases/latest/download/sub miner?sig='\\''abc'\\''' -o '/usr/local/bin/subminer'\\''s launcher' && sudo chmod +x '/usr/local/bin/subminer'\\''s launcher'", 'sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer && sudo chmod +x /usr/local/bin/subminer',
); );
}); });
@@ -84,7 +84,7 @@ test('updateLauncherAtPath reports protected command without replacing non-writa
}); });
assert.equal(result.status, 'protected'); assert.equal(result.status, 'protected');
assert.match(result.command ?? '', /^sudo curl -fSL 'https:\/\/example\.test\/subminer'/); assert.match(result.command ?? '', /^sudo curl -fSL https:\/\/example\.test\/subminer/);
}); });
test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher content', async () => { test('updateLauncherAtPath aborts on hash mismatch and suspicious launcher content', async () => {
+1 -5
View File
@@ -50,17 +50,13 @@ export function buildProtectedLauncherUpdateCommand(
assetUrl: string, assetUrl: string,
launcherPath: string, launcherPath: string,
): string { ): string {
return `sudo curl -fSL ${shellQuote(assetUrl)} -o ${shellQuote(launcherPath)} && sudo chmod +x ${shellQuote(launcherPath)}`; return `sudo curl -fSL ${assetUrl} -o ${launcherPath} && sudo chmod +x ${launcherPath}`;
} }
function sha256(data: Buffer): string { function sha256(data: Buffer): string {
return createHash('sha256').update(data).digest('hex'); return createHash('sha256').update(data).digest('hex');
} }
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function defaultFs(): LauncherUpdateFileSystem { function defaultFs(): LauncherUpdateFileSystem {
return { return {
readFile: (targetPath) => fs.promises.readFile(targetPath), readFile: (targetPath) => fs.promises.readFile(targetPath),
@@ -1,103 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
buildProtectedSupportAssetsCommand,
detectSupportAssetDataDirs,
updateSupportAssetsFromRelease,
} from './support-assets';
function sha256(data: Buffer): string {
return createHash('sha256').update(data).digest('hex');
}
function makeSupportAssetsArchive(): { archive: Buffer; tempDir: string } {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-support-assets-test-'));
fs.mkdirSync(path.join(tempDir, 'assets/themes'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'plugin/subminer'), { recursive: true });
fs.writeFileSync(path.join(tempDir, 'assets/themes/subminer.rasi'), 'new theme\n');
fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), 'new plugin\n');
execFileSync('tar', ['-czf', 'subminer-assets.tar.gz', 'assets', 'plugin'], { cwd: tempDir });
return {
archive: fs.readFileSync(path.join(tempDir, 'subminer-assets.tar.gz')),
tempDir,
};
}
test('detectSupportAssetDataDirs only returns Linux rofi theme locations', () => {
assert.deepEqual(
detectSupportAssetDataDirs({
platform: 'darwin',
homeDir: '/Users/kyle',
}),
[],
);
assert.deepEqual(
detectSupportAssetDataDirs({
platform: 'linux',
homeDir: '/home/kyle',
xdgDataHome: '/tmp/xdg-data',
}),
['/tmp/xdg-data/SubMiner', '/usr/local/share/SubMiner', '/usr/share/SubMiner'],
);
});
test('buildProtectedSupportAssetsCommand cleans up temporary extraction directory', () => {
const command = buildProtectedSupportAssetsCommand(
"https://example.test/subminer assets.tar.gz?sig='abc'",
"/usr/local/share/SubMiner's data",
);
assert.match(command, /tmp=\$\(mktemp -d\)/);
assert.match(command, /trap 'rm -rf "\$tmp"' EXIT/);
assert.match(
command,
/curl -fSL 'https:\/\/example\.test\/subminer assets\.tar\.gz\?sig='\\''abc'\\''' -o "\$tmp\/subminer-assets\.tar\.gz"/,
);
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/themes/);
});
test('updateSupportAssetsFromRelease updates only the Linux rofi theme', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n');
const { archive, tempDir } = makeSupportAssetsArchive();
try {
const results = await updateSupportAssetsFromRelease({
release: {
tag_name: 'v0.15.0',
assets: [
{
name: 'subminer-assets.tar.gz',
browser_download_url: 'https://example.test/subminer-assets.tar.gz',
},
],
},
sha256Sums: new Map([['subminer-assets.tar.gz', sha256(archive)]]),
downloadAsset: async () => archive,
platform: 'linux',
xdgDataHome,
});
assert.deepEqual(results, [{ status: 'updated', path: dataDir }]);
assert.equal(
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
'new theme\n',
);
assert.equal(
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
'old plugin\n',
);
} finally {
fs.rmSync(xdgDataHome, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

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