mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-16 15:13:31 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
5a98397efe
|
|||
| a117c5759c | |||
| ae7e6f82a8 | |||
| 1158be5b39 | |||
| 33e767458f |
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: notifications
|
||||||
|
|
||||||
|
- Restored the SubMiner app icon on system notifications that do not provide a custom notification image.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: updates
|
||||||
|
|
||||||
|
- Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets if either one is missing.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updates
|
||||||
|
|
||||||
|
- Fixed Linux updates so the managed support-asset install now creates and refreshes both the launcher runtime plugin copy and the rofi theme alongside AppImage and launcher updates.
|
||||||
|
- Fixed Linux support-asset refreshes so unrelated SubMiner data directories are left alone and plugin copies are staged before replacing the live runtime plugin.
|
||||||
|
- Fixed first Linux launcher playback on fresh installs by auto-installing the managed runtime plugin copy and rofi theme from the bundled app before mpv starts when either support asset is missing.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed macOS Yomitan popup focus after card mining or popup reload while still allowing click-away to close the popup without a hide/reappear cycle.
|
||||||
|
- Fixed macOS Yomitan popups staying open when clicking transparent overlay space; click-away is captured for popup close, then passthrough returns to mpv.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed auto-paused Linux visible overlay startup so the overlay stays interactive during the first measurement gap, startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv `sub-text` refreshes parsed cues before synthetic warm readiness can resume playback.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Kept the visible overlay active while mpv advances to the next playlist item, even when the next episode loads after the warm transition delay.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: internal
|
||||||
|
area: runtime
|
||||||
|
|
||||||
|
- Split main-process runtime wiring into focused modules without changing user-facing behavior.
|
||||||
|
- Hardened split runtime helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
||||||
+25
-20
@@ -12,20 +12,20 @@ Three steps to get started:
|
|||||||
|
|
||||||
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
||||||
|
|
||||||
| Dependency | Status | What it does |
|
| Dependency | Status | What it does |
|
||||||
| -------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
||||||
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
||||||
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
||||||
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
|
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
|
||||||
| fzf | Optional | Terminal-based video picker in the launcher. |
|
| fzf | Optional | Terminal-based video picker in the launcher. |
|
||||||
| rofi | Optional | GUI-based video picker (Linux). |
|
| rofi | Optional | GUI-based video picker (Linux). |
|
||||||
| chafa | Optional | Thumbnail previews in fzf. |
|
| chafa | Optional | Thumbnail previews in fzf. |
|
||||||
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
|
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
|
||||||
| guessit | Optional | Better AniSkip title/season/episode parsing. |
|
| guessit | Optional | Better AniSkip title/season/episode parsing. |
|
||||||
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
|
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
|
||||||
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
|
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
|
||||||
| fuse2 | Linux only | Required to run the AppImage. |
|
| fuse2 | Linux only | Required to run the AppImage. |
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
@@ -300,11 +300,11 @@ subminer -u
|
|||||||
subminer --update
|
subminer --update
|
||||||
```
|
```
|
||||||
|
|
||||||
SubMiner verifies AppImage, launcher, and rofi theme downloads against `SHA256SUMS.txt`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer` plus the rofi theme at `SubMiner/themes/subminer.rasi`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
||||||
|
|
||||||
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
|
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater` and refreshes the managed support assets from `subminer-assets.tar.gz`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
|
||||||
|
|
||||||
`subminer -u` also performs the AppImage update directly from the launcher process, which is useful when SubMiner is not currently running.
|
`subminer -u` also performs the AppImage, launcher, and managed support-asset updates directly from the launcher process, which is useful when SubMiner is not currently running.
|
||||||
|
|
||||||
## How It All Fits Together
|
## How It All Fits Together
|
||||||
|
|
||||||
@@ -312,13 +312,14 @@ SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IP
|
|||||||
|
|
||||||
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
|
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
|
||||||
|
|
||||||
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher now checks for its managed runtime plugin copy and rofi theme before every mpv-managed launch and installs those support assets from the bundled app automatically if either one is missing. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
||||||
|
|
||||||
## Platform Notes
|
## Platform Notes
|
||||||
|
|
||||||
### macOS
|
### macOS
|
||||||
|
|
||||||
**MeCab paths (Homebrew):**
|
**MeCab paths (Homebrew):**
|
||||||
|
|
||||||
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
|
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
|
||||||
- Intel: `/usr/local/bin/mecab`
|
- Intel: `/usr/local/bin/mecab`
|
||||||
|
|
||||||
@@ -361,17 +362,21 @@ sudo chmod +x /usr/local/bin/subminer
|
|||||||
|
|
||||||
## Optional Extras
|
## Optional Extras
|
||||||
|
|
||||||
### Rofi Theme (Linux Only)
|
### Linux Support Assets
|
||||||
|
|
||||||
SubMiner ships a custom rofi theme in the release assets:
|
SubMiner ships the Linux rofi theme plus the launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
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/themes
|
||||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
||||||
|
mkdir -p ~/.local/share/SubMiner/plugin
|
||||||
|
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
||||||
```
|
```
|
||||||
|
|
||||||
Override with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
|
`subminer -u` and the tray updater keep those Linux support assets in sync automatically once the `SubMiner` data dir exists. Normal Linux launcher playback also auto-installs the managed runtime plugin copy and rofi theme from the bundled app if either support asset is missing, so manual extraction is mainly useful for pre-seeding or custom setups.
|
||||||
|
|
||||||
|
Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
|
||||||
|
|
||||||
Next: [Usage](/usage) - learn about the `subminer` wrapper, keybindings, and YouTube playback.
|
Next: [Usage](/usage) - learn about the `subminer` wrapper, keybindings, and YouTube playback.
|
||||||
|
|||||||
@@ -34,15 +34,19 @@ subminer -R -r -d ~/Anime # rofi picker, recursive
|
|||||||
subminer -R /directory # rofi picker, directory shortcut
|
subminer -R /directory # rofi picker, directory shortcut
|
||||||
```
|
```
|
||||||
|
|
||||||
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships a custom rofi theme bundled in the release assets tarball:
|
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships the rofi theme plus the Linux launcher-managed runtime plugin copy in the release assets tarball:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
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/themes
|
||||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
||||||
|
mkdir -p ~/.local/share/SubMiner/plugin
|
||||||
|
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Once the `SubMiner` data dir exists, `subminer -u` refreshes both assets automatically. Normal Linux launcher playback also checks for the managed runtime plugin copy and rofi theme before mpv launch and installs them from the bundled app automatically if either one is missing.
|
||||||
|
|
||||||
The theme is auto-detected from these paths (first match wins):
|
The theme is auto-detected from these paths (first match wins):
|
||||||
|
|
||||||
- `$SUBMINER_ROFI_THEME` environment variable (absolute path)
|
- `$SUBMINER_ROFI_THEME` environment variable (absolute path)
|
||||||
@@ -113,7 +117,7 @@ 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.
|
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, runtime plugin copy, 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.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ The plugin ships as a modular Lua package under `plugin/subminer/` (entry point
|
|||||||
|
|
||||||
Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched.
|
Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched.
|
||||||
|
|
||||||
|
On Linux, the launcher-managed runtime plugin copy lives under the SubMiner data dir (`$XDG_DATA_HOME/SubMiner/plugin/subminer` by default, plus `/usr/local/share/SubMiner` or `/usr/share/SubMiner` for system installs). `subminer -u` and the tray updater keep that managed copy current. This is separate from mpv's global `scripts/` directory.
|
||||||
|
|
||||||
If setup detects an older global SubMiner plugin in mpv's `scripts` directory, use **Remove legacy mpv plugin** in first-run setup. The global plugin is not needed once runtime loading is available.
|
If setup detects an older global SubMiner plugin in mpv's `scripts` directory, use **Remove legacy mpv plugin** in first-run setup. The global plugin is not needed once runtime loading is available.
|
||||||
|
|
||||||
mpv must have IPC enabled for SubMiner to connect:
|
mpv must have IPC enabled for SubMiner to connect:
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
|
|||||||
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
|
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
|
||||||
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
|
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
|
||||||
|
|
||||||
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
||||||
|
|
||||||
## Live Config Reload
|
## Live Config Reload
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Subtitle Overlay Priming
|
# Subtitle Overlay Priming
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-06-01
|
Last verified: 2026-06-14
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||||
|
|
||||||
@@ -79,9 +79,19 @@ prefetch work and re-centers prefetch around the live playback time.
|
|||||||
window so readiness can still arrive before fallback resumes playback.
|
window so readiness can still arrive before fallback resumes playback.
|
||||||
- If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and
|
- If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and
|
||||||
waits for a fresh measured subtitle rectangle before signaling readiness.
|
waits for a fresh measured subtitle rectangle before signaling readiness.
|
||||||
|
- If the startup subtitle has no cached annotations yet, autoplay priming emits a raw first-paint
|
||||||
|
subtitle payload before background tokenization. The tokenized payload replaces it when ready, but
|
||||||
|
the visible overlay can paint and measure the line before the mpv startup gate resumes playback.
|
||||||
|
- If startup `sub-text` is temporarily empty, autoplay priming refreshes the active subtitle source
|
||||||
|
and then awaits cue-based priming before synthetic warm readiness can proceed. A parsed current or
|
||||||
|
imminent cue is treated as the startup subtitle so the visible overlay can paint and measure it
|
||||||
|
before playback resumes.
|
||||||
- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after
|
- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after
|
||||||
tokenization warmup and visible overlay content-ready. This releases playback without waiting for
|
tokenization warmup and visible overlay content-ready. This releases playback without waiting for
|
||||||
a later subtitle event that cannot happen while mpv is paused.
|
a later subtitle event that cannot happen while mpv is paused.
|
||||||
|
- After a synthetic warm readiness release, SubMiner briefly polls/refreshes the current subtitle
|
||||||
|
again. This covers Linux/mpv startup cases where `sub-text` is still empty while paused but becomes
|
||||||
|
available right after playback resumes, without waiting for the next subtitle property change.
|
||||||
|
|
||||||
## Linux/X11 Window Shape
|
## Linux/X11 Window Shape
|
||||||
|
|
||||||
@@ -95,6 +105,15 @@ prefetch work and re-centers prefetch around the live playback time.
|
|||||||
overlay window remained mapped above mpv.
|
overlay window remained mapped above mpv.
|
||||||
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
|
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
|
||||||
the Linux cursor-poll fallback, not bounding-shape clipping.
|
the Linux cursor-poll fallback, not bounding-shape clipping.
|
||||||
|
- Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical
|
||||||
|
interaction state is already inactive. The next cursor-poll tick must still reapply
|
||||||
|
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
|
||||||
|
full-window input capture and block both mpv and overlay controls before the first subtitle
|
||||||
|
measurement.
|
||||||
|
- Visible-overlay show also starts a short Linux input grace before the first content measurement.
|
||||||
|
Native Wayland surfaces can become inert while `setIgnoreMouseEvents(true)` is active; keeping the
|
||||||
|
overlay interactive during this startup gap lets notifications and overlay mouse bindings work
|
||||||
|
until subtitle/sidebar/notification rectangles are reported.
|
||||||
|
|
||||||
## Config And Migration
|
## Config And Migration
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { runConfigCommand } from './config-command.js';
|
|||||||
import { runDictionaryCommand } from './dictionary-command.js';
|
import { runDictionaryCommand } from './dictionary-command.js';
|
||||||
import { runDoctorCommand } from './doctor-command.js';
|
import { runDoctorCommand } from './doctor-command.js';
|
||||||
import { runLogsCommand } from './logs-command.js';
|
import { runLogsCommand } from './logs-command.js';
|
||||||
import { runMpvPreAppCommand } from './mpv-command.js';
|
import { runMpvPostAppCommand, runMpvPreAppCommand } from './mpv-command.js';
|
||||||
import { runAppPassthroughCommand } from './app-command.js';
|
import { runAppPassthroughCommand } from './app-command.js';
|
||||||
import { runStatsCommand } from './stats-command.js';
|
import { runStatsCommand } from './stats-command.js';
|
||||||
import { runUpdateCommand } from './update-command.js';
|
import { runUpdateCommand } from './update-command.js';
|
||||||
@@ -262,7 +262,9 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
|
|||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
async () => {
|
async () => {
|
||||||
await runMpvPreAppCommand(context, {
|
await runMpvPreAppCommand(context, {
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
waitForUnixSocketReady: async () => false,
|
waitForUnixSocketReady: async () => false,
|
||||||
|
resolveRuntimePluginPath: () => null,
|
||||||
launchMpvIdleDetached: async () => {},
|
launchMpvIdleDetached: async () => {},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -270,6 +272,32 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('mpv idle command ensures Linux runtime plugin before detached launch', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args.mpvIdle = true;
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const handled = await runMpvPostAppCommand(context, {
|
||||||
|
ensureRuntimePluginReady: async () => {
|
||||||
|
calls.push('plugin');
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => {
|
||||||
|
calls.push('wait');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
launchMpvIdleDetached: async () => {
|
||||||
|
calls.push('launch');
|
||||||
|
},
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return '/tmp/plugin/main.lua';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
|
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait']);
|
||||||
|
});
|
||||||
|
|
||||||
test('dictionary command forwards --dictionary and target path to app binary', () => {
|
test('dictionary command forwards --dictionary and target path to app binary', () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args.dictionary = true;
|
context.args.dictionary = true;
|
||||||
@@ -361,7 +389,7 @@ test('update command runs direct Linux release update without launching Electron
|
|||||||
'direct:/tmp/subminer.app:/tmp/subminer:stable',
|
'direct:/tmp/subminer.app:/tmp/subminer:stable',
|
||||||
'info:AppImage update: not-found',
|
'info:AppImage update: not-found',
|
||||||
'info:Launcher update: updated',
|
'info:Launcher update: updated',
|
||||||
'info:Rofi theme update: skipped',
|
'info:Support assets update: skipped',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import {
|
|||||||
resolveLauncherRuntimePluginPath,
|
resolveLauncherRuntimePluginPath,
|
||||||
} from '../mpv.js';
|
} from '../mpv.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
|
||||||
|
|
||||||
interface MpvCommandDeps {
|
interface MpvCommandDeps {
|
||||||
|
ensureRuntimePluginReady(context: LauncherCommandContext): Promise<void>;
|
||||||
waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise<boolean>;
|
waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise<boolean>;
|
||||||
|
resolveRuntimePluginPath(context: LauncherCommandContext): string | null;
|
||||||
launchMpvIdleDetached(
|
launchMpvIdleDetached(
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
appPath: string,
|
appPath: string,
|
||||||
@@ -18,7 +21,19 @@ interface MpvCommandDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultDeps: MpvCommandDeps = {
|
const defaultDeps: MpvCommandDeps = {
|
||||||
|
ensureRuntimePluginReady: async (context) => {
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
appPath: context.appPath ?? undefined,
|
||||||
|
scriptPath: context.scriptPath,
|
||||||
|
logLevel: context.args.logLevel,
|
||||||
|
});
|
||||||
|
},
|
||||||
waitForUnixSocketReady,
|
waitForUnixSocketReady,
|
||||||
|
resolveRuntimePluginPath: (context) =>
|
||||||
|
resolveLauncherRuntimePluginPath({
|
||||||
|
appPath: context.appPath ?? '',
|
||||||
|
scriptPath: context.scriptPath,
|
||||||
|
}),
|
||||||
launchMpvIdleDetached,
|
launchMpvIdleDetached,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,11 +73,12 @@ export async function runMpvPostAppCommand(
|
|||||||
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await deps.ensureRuntimePluginReady(context);
|
||||||
await deps.launchMpvIdleDetached(
|
await deps.launchMpvIdleDetached(
|
||||||
mpvSocketPath,
|
mpvSocketPath,
|
||||||
appPath,
|
appPath,
|
||||||
args,
|
args,
|
||||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
deps.resolveRuntimePluginPath(context),
|
||||||
{
|
{
|
||||||
...pluginRuntimeConfig,
|
...pluginRuntimeConfig,
|
||||||
backend: args.backend,
|
backend: args.backend,
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
|||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
|
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
|
||||||
checkDependencies: () => {},
|
checkDependencies: () => {},
|
||||||
registerCleanup: () => {},
|
registerCleanup: () => {},
|
||||||
@@ -161,6 +162,7 @@ test('youtube app-owned playback disables mpv plugin auto-start', async () => {
|
|||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
|
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
|
||||||
checkDependencies: () => {},
|
checkDependencies: () => {},
|
||||||
registerCleanup: () => {},
|
registerCleanup: () => {},
|
||||||
@@ -227,6 +229,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
|||||||
try {
|
try {
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
checkDependencies: () => {},
|
checkDependencies: () => {},
|
||||||
registerCleanup: () => {},
|
registerCleanup: () => {},
|
||||||
@@ -278,6 +281,7 @@ test('plugin auto-start playback attaches a warm background app through the laun
|
|||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
checkDependencies: () => {},
|
checkDependencies: () => {},
|
||||||
registerCleanup: () => {},
|
registerCleanup: () => {},
|
||||||
@@ -351,6 +355,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
|||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
checkDependencies: () => {},
|
checkDependencies: () => {},
|
||||||
registerCleanup: () => {},
|
registerCleanup: () => {},
|
||||||
@@ -420,6 +425,7 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
|
|||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {},
|
||||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
checkDependencies: () => {},
|
checkDependencies: () => {},
|
||||||
registerCleanup: () => {},
|
registerCleanup: () => {},
|
||||||
@@ -441,3 +447,34 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
|
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args = {
|
||||||
|
...context.args,
|
||||||
|
target: '/tmp/movie.mkv',
|
||||||
|
targetKind: 'file',
|
||||||
|
};
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await runPlaybackCommandWithDeps(context, {
|
||||||
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {
|
||||||
|
calls.push('plugin');
|
||||||
|
},
|
||||||
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
|
checkDependencies: () => {},
|
||||||
|
registerCleanup: () => {},
|
||||||
|
startMpv: async () => {
|
||||||
|
calls.push('startMpv');
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
startOverlay: async () => {},
|
||||||
|
launchAppCommandDetached: () => {},
|
||||||
|
log: () => {},
|
||||||
|
cleanupPlaybackSession: async () => {},
|
||||||
|
getMpvProc: () => null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['plugin', 'startMpv']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { Args } from '../types.js';
|
|||||||
import { nowMs } from '../time.js';
|
import { nowMs } from '../time.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import { ensureLauncherSetupReady } from '../setup-gate.js';
|
import { ensureLauncherSetupReady } from '../setup-gate.js';
|
||||||
|
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
|
||||||
import {
|
import {
|
||||||
getDefaultConfigDir,
|
getDefaultConfigDir,
|
||||||
getSetupStatePath,
|
getSetupStatePath,
|
||||||
@@ -144,6 +145,13 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
|||||||
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
||||||
return runPlaybackCommandWithDeps(context, {
|
return runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady,
|
ensurePlaybackSetupReady,
|
||||||
|
ensureRuntimePluginReady: async (commandContext) => {
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
appPath: commandContext.appPath ?? undefined,
|
||||||
|
scriptPath: commandContext.scriptPath,
|
||||||
|
logLevel: commandContext.args.logLevel,
|
||||||
|
});
|
||||||
|
},
|
||||||
chooseTarget,
|
chooseTarget,
|
||||||
checkDependencies,
|
checkDependencies,
|
||||||
registerCleanup,
|
registerCleanup,
|
||||||
@@ -160,6 +168,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
|
|
||||||
type PlaybackCommandDeps = {
|
type PlaybackCommandDeps = {
|
||||||
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
|
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
|
||||||
|
ensureRuntimePluginReady: (context: LauncherCommandContext) => Promise<void>;
|
||||||
chooseTarget: (
|
chooseTarget: (
|
||||||
args: Args,
|
args: Args,
|
||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
@@ -253,6 +262,8 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await deps.ensureRuntimePluginReady(context);
|
||||||
|
|
||||||
await deps.startMpv(
|
await deps.startMpv(
|
||||||
selectedTarget.target,
|
selectedTarget.target,
|
||||||
selectedTarget.kind,
|
selectedTarget.kind,
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
|
|||||||
return {
|
return {
|
||||||
appImage: { status: 'updated' },
|
appImage: { status: 'updated' },
|
||||||
launcher: { status: 'updated' },
|
launcher: { status: 'updated' },
|
||||||
supportAssets: [{ status: 'skipped' }],
|
supportAssets: [
|
||||||
|
{ status: 'updated', component: 'theme', message: 'Installed theme.' },
|
||||||
|
{ status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
|
readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
|
||||||
@@ -48,7 +51,8 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
|
|||||||
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
|
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
|
||||||
'info:AppImage update: updated',
|
'info:AppImage update: updated',
|
||||||
'info:Launcher update: updated',
|
'info:Launcher update: updated',
|
||||||
'info:Rofi theme update: skipped',
|
'info:Support assets (theme) update: updated - Installed theme.',
|
||||||
|
'info:Support assets (plugin) update: skipped - Plugin already up to date.',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,7 +105,7 @@ test('runUpdateCommand skips Linux asset replacement when release is not newer',
|
|||||||
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
|
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
|
||||||
'info:AppImage update: up to date',
|
'info:AppImage update: up to date',
|
||||||
'info:Launcher update: up to date',
|
'info:Launcher update: up to date',
|
||||||
'info:Rofi theme update: up to date',
|
'info:Support assets update: up to date',
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ type DirectReleaseUpdateRequest = {
|
|||||||
type DirectReleaseUpdateResult = {
|
type DirectReleaseUpdateResult = {
|
||||||
appImage: { status: string; command?: string; message?: string };
|
appImage: { status: string; command?: string; message?: string };
|
||||||
launcher: { status: string; command?: string; message?: string };
|
launcher: { status: string; command?: string; message?: string };
|
||||||
supportAssets: Array<{ status: string; command?: string; message?: string }>;
|
supportAssets: Array<{
|
||||||
|
status: string;
|
||||||
|
component?: 'theme' | 'plugin';
|
||||||
|
command?: string;
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UpdateCommandDeps = {
|
type UpdateCommandDeps = {
|
||||||
@@ -124,20 +129,29 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
|
|||||||
|
|
||||||
function logUpdateResult(
|
function logUpdateResult(
|
||||||
label: string,
|
label: string,
|
||||||
result: { status: string; command?: string; message?: string },
|
result: {
|
||||||
|
status: string;
|
||||||
|
component?: 'theme' | 'plugin';
|
||||||
|
command?: string;
|
||||||
|
message?: string;
|
||||||
|
},
|
||||||
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
|
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
|
||||||
deps: Pick<UpdateCommandDeps, 'log'>,
|
deps: Pick<UpdateCommandDeps, 'log'>,
|
||||||
): void {
|
): void {
|
||||||
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
|
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
|
||||||
deps.log('info', configuredLogLevel, `${label} update: ${displayStatus}`);
|
const componentLabel = result.component ? ` (${result.component})` : '';
|
||||||
|
const detailSuffix = result.message ? ` - ${result.message}` : '';
|
||||||
|
deps.log(
|
||||||
|
'info',
|
||||||
|
configuredLogLevel,
|
||||||
|
`${label}${componentLabel} update: ${displayStatus}${detailSuffix}`,
|
||||||
|
);
|
||||||
if (result.command) {
|
if (result.command) {
|
||||||
deps.log(
|
deps.log(
|
||||||
'warn',
|
'warn',
|
||||||
configuredLogLevel,
|
configuredLogLevel,
|
||||||
`${label} update requires manual command: ${result.command}`,
|
`${label}${componentLabel} update requires manual command: ${result.command}`,
|
||||||
);
|
);
|
||||||
} else if (result.message) {
|
|
||||||
deps.log('warn', configuredLogLevel, `${label} update note: ${result.message}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +201,7 @@ export async function runUpdateCommand(
|
|||||||
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
|
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
|
||||||
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
|
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
|
||||||
for (const supportResult of result.supportAssets) {
|
for (const supportResult of result.supportAssets) {
|
||||||
logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps);
|
logUpdateResult('Support assets', supportResult, logLevel, resolvedDeps);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { Args } from './types.js';
|
||||||
|
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
|
||||||
|
|
||||||
|
function createArgs(): Args {
|
||||||
|
return {
|
||||||
|
backend: 'auto',
|
||||||
|
directory: '.',
|
||||||
|
recursive: false,
|
||||||
|
profile: '',
|
||||||
|
startOverlay: false,
|
||||||
|
youtubeMode: 'download',
|
||||||
|
whisperBin: '',
|
||||||
|
whisperModel: '',
|
||||||
|
whisperVadModel: '',
|
||||||
|
whisperThreads: 0,
|
||||||
|
youtubeSubgenOutDir: '',
|
||||||
|
youtubeSubgenAudioFormat: '',
|
||||||
|
youtubeSubgenKeepTemp: false,
|
||||||
|
youtubeFixWithAi: false,
|
||||||
|
youtubePrimarySubLangs: [],
|
||||||
|
youtubeSecondarySubLangs: [],
|
||||||
|
youtubeAudioLangs: [],
|
||||||
|
youtubeWhisperSourceLanguage: '',
|
||||||
|
aiConfig: {},
|
||||||
|
useTexthooker: false,
|
||||||
|
autoStartOverlay: false,
|
||||||
|
texthookerOnly: false,
|
||||||
|
texthookerOpenBrowser: false,
|
||||||
|
useRofi: false,
|
||||||
|
logLevel: 'info',
|
||||||
|
logRotation: 7,
|
||||||
|
passwordStore: '',
|
||||||
|
target: '',
|
||||||
|
targetKind: '',
|
||||||
|
jimakuApiKey: '',
|
||||||
|
jimakuApiKeyCommand: '',
|
||||||
|
jimakuApiBaseUrl: '',
|
||||||
|
jimakuLanguagePreference: 'ja',
|
||||||
|
jimakuMaxEntryResults: 20,
|
||||||
|
jellyfin: false,
|
||||||
|
jellyfinLogin: false,
|
||||||
|
jellyfinLogout: false,
|
||||||
|
jellyfinPlay: false,
|
||||||
|
jellyfinDiscovery: false,
|
||||||
|
dictionary: false,
|
||||||
|
dictionaryCandidates: false,
|
||||||
|
dictionarySelect: false,
|
||||||
|
stats: false,
|
||||||
|
doctor: false,
|
||||||
|
doctorRefreshKnownWords: false,
|
||||||
|
logsExport: false,
|
||||||
|
version: false,
|
||||||
|
settings: false,
|
||||||
|
configPath: false,
|
||||||
|
configShow: false,
|
||||||
|
mpvIdle: false,
|
||||||
|
mpvSocket: false,
|
||||||
|
mpvStatus: false,
|
||||||
|
mpvArgs: '',
|
||||||
|
appPassthrough: false,
|
||||||
|
appArgs: [],
|
||||||
|
jellyfinServer: 'https://jellyfin.example.test',
|
||||||
|
jellyfinUsername: '',
|
||||||
|
jellyfinPassword: '',
|
||||||
|
launchMode: 'normal',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
|
||||||
|
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
||||||
|
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
|
||||||
|
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
|
||||||
|
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runJellyfinPlayMenuWithDeps(
|
||||||
|
'/tmp/SubMiner.AppImage',
|
||||||
|
createArgs(),
|
||||||
|
'/tmp/subminer',
|
||||||
|
'/tmp/subminer.sock',
|
||||||
|
{
|
||||||
|
loadLauncherJellyfinConfig: () => ({}),
|
||||||
|
findRofiTheme: () => null,
|
||||||
|
resolveJellyfinSelection: async () => 'item-123',
|
||||||
|
resolveJellyfinSelectionViaApp: async () => {
|
||||||
|
throw new Error('unexpected app-based selection');
|
||||||
|
},
|
||||||
|
hasStoredJellyfinSession: () => true,
|
||||||
|
requestJellyfinPreviewAuthFromApp: async () => null,
|
||||||
|
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||||
|
pathExists: () => false,
|
||||||
|
ensureRuntimePluginReady: async () => {
|
||||||
|
calls.push('plugin');
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => {
|
||||||
|
calls.push('wait');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
launchMpvIdleDetached: async () => {
|
||||||
|
calls.push('launch');
|
||||||
|
},
|
||||||
|
resolveLauncherRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return '/tmp/plugin/main.lua';
|
||||||
|
},
|
||||||
|
runAppCommandWithInheritLogged: () => {
|
||||||
|
calls.push('handoff');
|
||||||
|
throw new Error('stop after handoff');
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/stop after handoff/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
|
||||||
|
} finally {
|
||||||
|
if (originalAccessToken === undefined) {
|
||||||
|
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
|
||||||
|
}
|
||||||
|
if (originalUserId === undefined) {
|
||||||
|
delete process.env.SUBMINER_JELLYFIN_USER_ID;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
+87
-19
@@ -30,9 +30,54 @@ import {
|
|||||||
resolveLauncherRuntimePluginPath,
|
resolveLauncherRuntimePluginPath,
|
||||||
waitForUnixSocketReady,
|
waitForUnixSocketReady,
|
||||||
} from './mpv.js';
|
} from './mpv.js';
|
||||||
|
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
|
||||||
|
|
||||||
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
|
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
|
||||||
|
|
||||||
|
type JellyfinPlayMenuDeps = {
|
||||||
|
loadLauncherJellyfinConfig: typeof loadLauncherJellyfinConfig;
|
||||||
|
findRofiTheme: typeof findRofiTheme;
|
||||||
|
resolveJellyfinSelection: typeof resolveJellyfinSelection;
|
||||||
|
hasStoredJellyfinSession: typeof hasStoredJellyfinSession;
|
||||||
|
requestJellyfinPreviewAuthFromApp: typeof requestJellyfinPreviewAuthFromApp;
|
||||||
|
resolveLauncherMainConfigPath: typeof resolveLauncherMainConfigPath;
|
||||||
|
resolveJellyfinSelectionViaApp: typeof resolveJellyfinSelectionViaApp;
|
||||||
|
pathExists: (candidate: string) => boolean;
|
||||||
|
ensureRuntimePluginReady: (options: {
|
||||||
|
appPath: string;
|
||||||
|
scriptPath: string;
|
||||||
|
logLevel: Args['logLevel'];
|
||||||
|
}) => Promise<void>;
|
||||||
|
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
||||||
|
launchMpvIdleDetached: typeof launchMpvIdleDetached;
|
||||||
|
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
|
||||||
|
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
|
||||||
|
log: typeof log;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
|
||||||
|
loadLauncherJellyfinConfig,
|
||||||
|
findRofiTheme,
|
||||||
|
resolveJellyfinSelection,
|
||||||
|
hasStoredJellyfinSession,
|
||||||
|
requestJellyfinPreviewAuthFromApp,
|
||||||
|
resolveLauncherMainConfigPath,
|
||||||
|
resolveJellyfinSelectionViaApp,
|
||||||
|
pathExists: (candidate) => fs.existsSync(candidate),
|
||||||
|
ensureRuntimePluginReady: async ({ appPath, scriptPath, logLevel }) => {
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
appPath,
|
||||||
|
scriptPath,
|
||||||
|
logLevel,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady,
|
||||||
|
launchMpvIdleDetached,
|
||||||
|
resolveLauncherRuntimePluginPath,
|
||||||
|
runAppCommandWithInheritLogged,
|
||||||
|
log,
|
||||||
|
};
|
||||||
|
|
||||||
export function sanitizeServerUrl(value: string): string {
|
export function sanitizeServerUrl(value: string): string {
|
||||||
return value.trim().replace(/\/+$/, '');
|
return value.trim().replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
@@ -974,7 +1019,17 @@ export async function runJellyfinPlayMenu(
|
|||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
mpvSocketPath: string,
|
mpvSocketPath: string,
|
||||||
): Promise<never> {
|
): Promise<never> {
|
||||||
const config = loadLauncherJellyfinConfig();
|
return runJellyfinPlayMenuWithDeps(appPath, args, scriptPath, mpvSocketPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runJellyfinPlayMenuWithDeps(
|
||||||
|
appPath: string,
|
||||||
|
args: Args,
|
||||||
|
scriptPath: string,
|
||||||
|
mpvSocketPath: string,
|
||||||
|
deps: JellyfinPlayMenuDeps = defaultJellyfinPlayMenuDeps,
|
||||||
|
): Promise<never> {
|
||||||
|
const config = deps.loadLauncherJellyfinConfig();
|
||||||
const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim();
|
const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim();
|
||||||
const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim();
|
const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim();
|
||||||
const session: JellyfinSessionConfig = {
|
const session: JellyfinSessionConfig = {
|
||||||
@@ -986,58 +1041,71 @@ export async function runJellyfinPlayMenu(
|
|||||||
iconCacheDir: config.iconCacheDir || '',
|
iconCacheDir: config.iconCacheDir || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
|
const rofiTheme = args.useRofi ? deps.findRofiTheme(scriptPath) : null;
|
||||||
if (args.useRofi && !rofiTheme) {
|
if (args.useRofi && !rofiTheme) {
|
||||||
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
|
deps.log(
|
||||||
|
'warn',
|
||||||
|
args.logLevel,
|
||||||
|
'Rofi theme not found for Jellyfin picker; using rofi defaults.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
|
const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
|
||||||
let itemId = '';
|
let itemId = '';
|
||||||
if (hasDirectSession) {
|
if (hasDirectSession) {
|
||||||
itemId = await resolveJellyfinSelection(args, session, rofiTheme);
|
itemId = await deps.resolveJellyfinSelection(args, session, rofiTheme);
|
||||||
} else {
|
} else {
|
||||||
const configPath = resolveLauncherMainConfigPath();
|
const configPath = deps.resolveLauncherMainConfigPath();
|
||||||
if (!hasStoredJellyfinSession(configPath)) {
|
if (!deps.hasStoredJellyfinSession(configPath)) {
|
||||||
fail(
|
fail(
|
||||||
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
|
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const previewAuth = await requestJellyfinPreviewAuthFromApp(appPath, args);
|
const previewAuth = await deps.requestJellyfinPreviewAuthFromApp(appPath, args);
|
||||||
if (previewAuth) {
|
if (previewAuth) {
|
||||||
session.serverUrl = previewAuth.serverUrl || session.serverUrl;
|
session.serverUrl = previewAuth.serverUrl || session.serverUrl;
|
||||||
session.accessToken = previewAuth.accessToken;
|
session.accessToken = previewAuth.accessToken;
|
||||||
session.userId = previewAuth.userId || session.userId;
|
session.userId = previewAuth.userId || session.userId;
|
||||||
log('debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.');
|
deps.log(
|
||||||
|
'debug',
|
||||||
|
args.logLevel,
|
||||||
|
'Jellyfin preview auth bridge ready for picker image previews.',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
log(
|
deps.log(
|
||||||
'debug',
|
'debug',
|
||||||
args.logLevel,
|
args.logLevel,
|
||||||
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
|
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
itemId = await resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
|
itemId = await deps.resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
|
||||||
}
|
}
|
||||||
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
|
deps.log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
|
||||||
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
|
deps.log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
|
||||||
let mpvReady = false;
|
let mpvReady = false;
|
||||||
if (fs.existsSync(mpvSocketPath)) {
|
if (deps.pathExists(mpvSocketPath)) {
|
||||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
|
mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 250);
|
||||||
}
|
}
|
||||||
if (!mpvReady) {
|
if (!mpvReady) {
|
||||||
await launchMpvIdleDetached(
|
await deps.ensureRuntimePluginReady({ appPath, scriptPath, logLevel: args.logLevel });
|
||||||
|
await deps.launchMpvIdleDetached(
|
||||||
mpvSocketPath,
|
mpvSocketPath,
|
||||||
appPath,
|
appPath,
|
||||||
args,
|
args,
|
||||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
deps.resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||||
);
|
);
|
||||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||||
}
|
}
|
||||||
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
|
deps.log(
|
||||||
|
'debug',
|
||||||
|
args.logLevel,
|
||||||
|
`MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`,
|
||||||
|
);
|
||||||
if (!mpvReady) {
|
if (!mpvReady) {
|
||||||
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
|
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
|
||||||
}
|
}
|
||||||
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
||||||
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
|
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
|
||||||
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||||
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import {
|
||||||
|
ensureLinuxRuntimePluginAvailable,
|
||||||
|
installManagedPluginAssetsViaApp,
|
||||||
|
} from './runtime-plugin-preflight';
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'darwin',
|
||||||
|
detectInstalledPlugin: () => {
|
||||||
|
calls.push('detect');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
log: () => {
|
||||||
|
calls.push('log');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
detectInstalledPlugin: () => {
|
||||||
|
calls.push('detect');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
isManagedThemeAvailable: () => {
|
||||||
|
calls.push('theme');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['detect', 'theme']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
xdgDataHome: '/tmp/xdg-data',
|
||||||
|
detectInstalledPlugin: () => {
|
||||||
|
calls.push('detect');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return '/tmp/plugin/main.lua';
|
||||||
|
},
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
isManagedThemeAvailable: () => {
|
||||||
|
calls.push('theme');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['detect', 'resolve', 'theme']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
xdgDataHome: '/tmp/xdg-data',
|
||||||
|
detectInstalledPlugin: () => {
|
||||||
|
calls.push('detect');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return '/tmp/plugin/main.lua';
|
||||||
|
},
|
||||||
|
isManagedThemeAvailable: () => {
|
||||||
|
calls.push('theme');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
log: (level, _configured, message) => {
|
||||||
|
calls.push(`${level}:${message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'detect',
|
||||||
|
'resolve',
|
||||||
|
'theme',
|
||||||
|
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||||
|
'install',
|
||||||
|
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||||
|
'resolve',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let resolveCount = 0;
|
||||||
|
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
xdgDataHome: '/tmp/xdg-data',
|
||||||
|
detectInstalledPlugin: () => false,
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
resolveCount += 1;
|
||||||
|
calls.push(`resolve:${resolveCount}`);
|
||||||
|
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
|
||||||
|
},
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
log: (level, _configured, message) => {
|
||||||
|
calls.push(`${level}:${message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'resolve:1',
|
||||||
|
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||||
|
'install',
|
||||||
|
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||||
|
'resolve:2',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
detectInstalledPlugin: () => false,
|
||||||
|
resolveRuntimePluginPath: () => null,
|
||||||
|
installManagedPluginAssets: async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'copy failed',
|
||||||
|
}),
|
||||||
|
log: () => {},
|
||||||
|
}),
|
||||||
|
/copy failed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
detectInstalledPlugin: () => false,
|
||||||
|
resolveRuntimePluginPath: () => null,
|
||||||
|
installManagedPluginAssets: async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: '/tmp/plugin/main.lua',
|
||||||
|
}),
|
||||||
|
log: () => {},
|
||||||
|
}),
|
||||||
|
/managed runtime plugin assets could not be installed/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => {
|
||||||
|
let waited = false;
|
||||||
|
|
||||||
|
const result = await installManagedPluginAssetsViaApp(
|
||||||
|
{
|
||||||
|
appPath: '/opt/SubMiner/subminer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
runAppCommandCaptureOutput: () => ({
|
||||||
|
status: 1,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
error: new Error('spawn failed'),
|
||||||
|
}),
|
||||||
|
waitForInstallResponse: async () => {
|
||||||
|
waited = true;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'spawn failed',
|
||||||
|
});
|
||||||
|
assert.equal(waited, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installManagedPluginAssetsViaApp does not let temp cleanup errors mask install result', async () => {
|
||||||
|
const originalRmSync = fs.rmSync;
|
||||||
|
fs.rmSync = ((targetPath, options) => {
|
||||||
|
if (String(targetPath).includes('subminer-runtime-plugin-')) {
|
||||||
|
throw new Error('cleanup failed');
|
||||||
|
}
|
||||||
|
return originalRmSync(targetPath, options);
|
||||||
|
}) as typeof fs.rmSync;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await installManagedPluginAssetsViaApp(
|
||||||
|
{
|
||||||
|
appPath: '/opt/SubMiner/subminer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
runAppCommandCaptureOutput: () => ({
|
||||||
|
status: 0,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
}),
|
||||||
|
waitForInstallResponse: async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: '/tmp/plugin/main.lua',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: '/tmp/plugin/main.lua',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.rmSync = originalRmSync;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { log as launcherLog } from './log.js';
|
||||||
|
import { runAppCommandCaptureOutput, resolveLauncherRuntimePluginPath } from './mpv.js';
|
||||||
|
import { nowMs } from './time.js';
|
||||||
|
import { sleep } from './util.js';
|
||||||
|
import { detectInstalledMpvPlugin } from '../src/main/runtime/first-run-setup-plugin.js';
|
||||||
|
import {
|
||||||
|
resolveManagedLinuxRuntimePluginPaths,
|
||||||
|
type EnsureLinuxRuntimePluginAssetsResult,
|
||||||
|
} from '../src/main/runtime/linux-runtime-plugin-assets.js';
|
||||||
|
|
||||||
|
const RESPONSE_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
type PreflightLog = (
|
||||||
|
level: 'debug' | 'info' | 'warn' | 'error',
|
||||||
|
configured: 'debug' | 'info' | 'warn' | 'error',
|
||||||
|
message: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type EnsureLinuxRuntimePluginAvailableOptions = {
|
||||||
|
appPath?: string;
|
||||||
|
scriptPath?: string;
|
||||||
|
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
homeDir?: string;
|
||||||
|
xdgConfigHome?: string;
|
||||||
|
xdgDataHome?: string;
|
||||||
|
appDataDir?: string;
|
||||||
|
detectInstalledPlugin?: () => boolean;
|
||||||
|
resolveRuntimePluginPath?: () => string | null;
|
||||||
|
isManagedThemeAvailable?: () => boolean;
|
||||||
|
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
|
||||||
|
log?: PreflightLog;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuntimePluginPreflightResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
status: 'installed' | 'already-present' | 'failed';
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveConfiguredLogLevel(
|
||||||
|
logLevel: EnsureLinuxRuntimePluginAvailableOptions['logLevel'],
|
||||||
|
): 'debug' | 'info' | 'warn' | 'error' {
|
||||||
|
return logLevel ?? 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForInstallResponse(
|
||||||
|
responsePath: string,
|
||||||
|
): Promise<RuntimePluginPreflightResponse | null> {
|
||||||
|
const deadline = nowMs() + RESPONSE_TIMEOUT_MS;
|
||||||
|
while (nowMs() < deadline) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(responsePath)) {
|
||||||
|
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as RuntimePluginPreflightResponse;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry until timeout
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstallManagedPluginAssetsViaAppDeps = {
|
||||||
|
runAppCommandCaptureOutput?: typeof runAppCommandCaptureOutput;
|
||||||
|
waitForInstallResponse?: typeof waitForInstallResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function installManagedPluginAssetsViaApp(
|
||||||
|
options: {
|
||||||
|
appPath: string;
|
||||||
|
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
},
|
||||||
|
deps: InstallManagedPluginAssetsViaAppDeps = {},
|
||||||
|
): Promise<EnsureLinuxRuntimePluginAssetsResult> {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-runtime-plugin-'));
|
||||||
|
const responsePath = path.join(tempDir, 'response.json');
|
||||||
|
const runAppCommand = deps.runAppCommandCaptureOutput ?? runAppCommandCaptureOutput;
|
||||||
|
const waitForResponse = deps.waitForInstallResponse ?? waitForInstallResponse;
|
||||||
|
try {
|
||||||
|
const appArgs = [
|
||||||
|
'--ensure-linux-runtime-plugin-assets',
|
||||||
|
'--ensure-linux-runtime-plugin-assets-response-path',
|
||||||
|
responsePath,
|
||||||
|
];
|
||||||
|
const result = runAppCommand(options.appPath, appArgs);
|
||||||
|
if (result.error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error: result.error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr.trim();
|
||||||
|
const stdout = result.stdout.trim();
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error:
|
||||||
|
stderr ||
|
||||||
|
stdout ||
|
||||||
|
`Linux runtime plugin asset install command exited with status ${result.status}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const response = await waitForResponse(responsePath);
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
const stderr = result.stderr.trim();
|
||||||
|
const stdout = result.stdout.trim();
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error:
|
||||||
|
stderr ||
|
||||||
|
stdout ||
|
||||||
|
`Timed out waiting for Linux runtime plugin asset response after app exit status ${result.status}.`,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Avoid hiding the install failure or success result behind temp cleanup errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureLinuxRuntimePluginAvailable(
|
||||||
|
options: EnsureLinuxRuntimePluginAvailableOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
if (platform !== 'linux') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredLogLevel = resolveConfiguredLogLevel(options.logLevel);
|
||||||
|
const log = options.log ?? launcherLog;
|
||||||
|
const homeDir = options.homeDir ?? os.homedir();
|
||||||
|
const detectInstalledPlugin =
|
||||||
|
options.detectInstalledPlugin ??
|
||||||
|
(() =>
|
||||||
|
detectInstalledMpvPlugin({
|
||||||
|
platform,
|
||||||
|
homeDir,
|
||||||
|
xdgConfigHome: options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
|
||||||
|
appDataDir: options.appDataDir ?? process.env.APPDATA,
|
||||||
|
}).installed);
|
||||||
|
const installedPluginAvailable = detectInstalledPlugin();
|
||||||
|
const managedPaths = resolveManagedLinuxRuntimePluginPaths({
|
||||||
|
homeDir,
|
||||||
|
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolveRuntimePluginPath =
|
||||||
|
options.resolveRuntimePluginPath ??
|
||||||
|
(() => {
|
||||||
|
if (!options.appPath) return null;
|
||||||
|
return resolveLauncherRuntimePluginPath({
|
||||||
|
appPath: options.appPath,
|
||||||
|
scriptPath: options.scriptPath,
|
||||||
|
platform,
|
||||||
|
homeDir,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const isManagedThemeAvailable =
|
||||||
|
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
|
||||||
|
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
|
||||||
|
if (runtimePluginAvailable && isManagedThemeAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
'info',
|
||||||
|
configuredLogLevel,
|
||||||
|
'Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||||
|
);
|
||||||
|
const installManagedPluginAssets =
|
||||||
|
options.installManagedPluginAssets ??
|
||||||
|
(() => {
|
||||||
|
if (!options.appPath) {
|
||||||
|
throw new Error(
|
||||||
|
'Linux managed runtime plugin assets could not be installed. Launch aborted before starting mpv.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return installManagedPluginAssetsViaApp({
|
||||||
|
appPath: options.appPath,
|
||||||
|
logLevel: options.logLevel,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const installResult = await installManagedPluginAssets();
|
||||||
|
if (!installResult.ok) {
|
||||||
|
const message = installResult.error || 'Unknown Linux runtime plugin asset install failure.';
|
||||||
|
log(
|
||||||
|
'warn',
|
||||||
|
configuredLogLevel,
|
||||||
|
`Managed Linux runtime support asset install failed: ${message}`,
|
||||||
|
);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
'info',
|
||||||
|
configuredLogLevel,
|
||||||
|
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`,
|
||||||
|
);
|
||||||
|
const runtimePluginPath = resolveRuntimePluginPath();
|
||||||
|
if (runtimePluginPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
`Linux managed runtime plugin assets could not be installed. ` +
|
||||||
|
`Checked path: ${managedPaths.pluginEntrypointPath}. ` +
|
||||||
|
'Launch aborted before starting mpv.';
|
||||||
|
log('warn', configuredLogLevel, message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ type SmokeCase = {
|
|||||||
artifactsDir: string;
|
artifactsDir: string;
|
||||||
binDir: string;
|
binDir: string;
|
||||||
xdgConfigHome: string;
|
xdgConfigHome: string;
|
||||||
|
xdgDataHome: string;
|
||||||
appDataDir: string;
|
appDataDir: string;
|
||||||
localAppDataDir: string;
|
localAppDataDir: string;
|
||||||
homeDir: string;
|
homeDir: string;
|
||||||
@@ -64,6 +65,7 @@ function createSmokeCase(name: string): SmokeCase {
|
|||||||
const artifactsDir = path.join(root, 'artifacts');
|
const artifactsDir = path.join(root, 'artifacts');
|
||||||
const binDir = path.join(root, 'bin');
|
const binDir = path.join(root, 'bin');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
|
const xdgDataHome = path.join(root, 'xdg-data');
|
||||||
const appDataDir = path.join(root, 'AppData', 'Roaming');
|
const appDataDir = path.join(root, 'AppData', 'Roaming');
|
||||||
const localAppDataDir = path.join(root, 'AppData', 'Local');
|
const localAppDataDir = path.join(root, 'AppData', 'Local');
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
@@ -135,6 +137,7 @@ process.on('SIGTERM', closeAndExit);
|
|||||||
fakeAppBasePath,
|
fakeAppBasePath,
|
||||||
`#!/usr/bin/env bun
|
`#!/usr/bin/env bun
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
||||||
const startPath = ${JSON.stringify(fakeAppStartLogPath)};
|
const startPath = ${JSON.stringify(fakeAppStartLogPath)};
|
||||||
@@ -154,6 +157,25 @@ if (entry.argv.includes('--stop')) {
|
|||||||
if (entry.argv.includes('--app-ping')) {
|
if (entry.argv.includes('--app-ping')) {
|
||||||
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
|
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
|
||||||
}
|
}
|
||||||
|
if (entry.argv.includes('--ensure-linux-runtime-plugin-assets')) {
|
||||||
|
const responseFlagIndex = entry.argv.indexOf('--ensure-linux-runtime-plugin-assets-response-path');
|
||||||
|
const responsePath = responseFlagIndex >= 0 ? entry.argv[responseFlagIndex + 1] : '';
|
||||||
|
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(process.env.HOME || '', '.local', 'share');
|
||||||
|
const dataDir = path.join(xdgDataHome, 'SubMiner');
|
||||||
|
const pluginDir = path.join(dataDir, 'plugin', 'subminer');
|
||||||
|
const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf');
|
||||||
|
const themePath = path.join(dataDir, 'themes', 'subminer.rasi');
|
||||||
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(themePath), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n');
|
||||||
|
fs.writeFileSync(pluginConfigPath, 'smoke=true\\n');
|
||||||
|
fs.writeFileSync(themePath, '/* smoke theme */\\n');
|
||||||
|
if (responsePath) {
|
||||||
|
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||||
|
fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') }));
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
`,
|
`,
|
||||||
@@ -164,6 +186,7 @@ process.exit(0);
|
|||||||
artifactsDir,
|
artifactsDir,
|
||||||
binDir,
|
binDir,
|
||||||
xdgConfigHome,
|
xdgConfigHome,
|
||||||
|
xdgDataHome,
|
||||||
appDataDir,
|
appDataDir,
|
||||||
localAppDataDir,
|
localAppDataDir,
|
||||||
homeDir,
|
homeDir,
|
||||||
@@ -181,6 +204,7 @@ function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
|
|||||||
...process.env,
|
...process.env,
|
||||||
HOME: smokeCase.homeDir,
|
HOME: smokeCase.homeDir,
|
||||||
XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
|
XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
|
||||||
|
XDG_DATA_HOME: smokeCase.xdgDataHome,
|
||||||
APPDATA: smokeCase.appDataDir,
|
APPDATA: smokeCase.appDataDir,
|
||||||
LOCALAPPDATA: smokeCase.localAppDataDir,
|
LOCALAPPDATA: smokeCase.localAppDataDir,
|
||||||
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
|
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
|
||||||
@@ -495,7 +519,7 @@ test(
|
|||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'launcher start-overlay attaches to a running background app without spawning another app command',
|
'launcher start-overlay attaches to a running background app without spawning another app start command',
|
||||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||||
async () => {
|
async () => {
|
||||||
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
||||||
@@ -530,7 +554,13 @@ test(
|
|||||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||||
|
|
||||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||||
assert.equal(appEntries.length, 0);
|
assert.equal(appEntries.length > 0, true);
|
||||||
|
assert.equal(
|
||||||
|
appEntries.every((entry) =>
|
||||||
|
(entry.argv as string[]).includes('--ensure-linux-runtime-plugin-assets'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
assert.equal(appStartEntries.length, 0);
|
assert.equal(appStartEntries.length, 0);
|
||||||
assert.equal(appStopEntries.length, 0);
|
assert.equal(appStopEntries.length, 0);
|
||||||
assert.equal(controlEntries.length, 1);
|
assert.equal(controlEntries.length, 1);
|
||||||
@@ -587,6 +617,11 @@ test(
|
|||||||
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
|
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
|
||||||
);
|
);
|
||||||
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
||||||
|
assert.match(result.stdout, /managed plugin\/theme assets/i);
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-3
File diff suppressed because one or more lines are too long
@@ -51,6 +51,15 @@ function M.create(ctx)
|
|||||||
return reason == "reload" or reason == "redirect"
|
return reason == "reload" or reason == "redirect"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function has_next_playlist_item()
|
||||||
|
local playlist_count = mp.get_property_number("playlist-count")
|
||||||
|
local playlist_pos = mp.get_property_number("playlist-pos")
|
||||||
|
if type(playlist_count) ~= "number" or type(playlist_pos) ~= "number" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return playlist_count > 0 and playlist_pos >= 0 and playlist_pos < playlist_count - 1
|
||||||
|
end
|
||||||
|
|
||||||
local function clear_pending_visible_overlay_hide()
|
local function clear_pending_visible_overlay_hide()
|
||||||
local timer = state.pending_visible_overlay_hide_timer
|
local timer = state.pending_visible_overlay_hide_timer
|
||||||
if timer and timer.kill then
|
if timer and timer.kill then
|
||||||
@@ -63,6 +72,9 @@ function M.create(ctx)
|
|||||||
local resolve_auto_start_visible_overlay_enabled
|
local resolve_auto_start_visible_overlay_enabled
|
||||||
|
|
||||||
local function hide_visible_overlay_after_end_file()
|
local function hide_visible_overlay_after_end_file()
|
||||||
|
if has_next_playlist_item() then
|
||||||
|
return
|
||||||
|
end
|
||||||
if state.visible_overlay_requested == true and not resolve_auto_start_visible_overlay_enabled() then
|
if state.visible_overlay_requested == true and not resolve_auto_start_visible_overlay_enabled() then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections.
|
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections.
|
||||||
- Includes click-to-learn keybinding controls, an AnkiConnect deck dropdown that auto-fills from Yomitan's current mining deck, and AnkiConnect-backed deck, field, and note-type pickers.
|
- Includes click-to-learn keybinding controls, an AnkiConnect deck dropdown that auto-fills from Yomitan's current mining deck, and AnkiConnect-backed deck, field, and note-type pickers.
|
||||||
- Live-saves changes for subtitle CSS declarations, stats keys, logging level, Anki field mappings, sentence card model, and other annotation and runtime options; search narrows across all categories including on multi-word terms. AI and translation settings remain config-file only.
|
- Live-saves changes for subtitle CSS declarations, stats keys, logging level, Anki field mappings, sentence card model, and other annotation and runtime options; search narrows across all categories including multi-word terms. AI and translation settings remain config-file only.
|
||||||
|
|
||||||
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`, with checksum verification and configurable update notifications.
|
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`, with checksum verification and configurable update notifications.
|
||||||
- The `subminer` launcher and Linux rofi theme update automatically alongside the app.
|
- The `subminer` launcher and Linux rofi theme update automatically alongside the app.
|
||||||
@@ -50,15 +50,27 @@
|
|||||||
|
|
||||||
- **Runtime:** The bundled Electron runtime is updated from 39.8.6 to 42.2.0.
|
- **Runtime:** The bundled Electron runtime is updated from 39.8.6 to 42.2.0.
|
||||||
|
|
||||||
|
- **Subtitle Delay Shortcuts:** Default overlay subtitle delay and step bindings now match mpv's own defaults.
|
||||||
|
- `z`, `Z`, and `x` adjust sub-delay; `Ctrl+Shift+Left/Right` runs native sub-step and shows the current delay on the OSD.
|
||||||
|
- The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||||
|
|
||||||
|
- **Update Notifications:** New installs default to overlay-only update notifications instead of overlay plus system notifications.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **macOS Overlay:** Significantly improved overlay focus and stability across a range of scenarios.
|
- **macOS Overlay:** Significantly improved overlay focus and stability across a range of scenarios.
|
||||||
- The overlay hides when mpv loses focus, is minimized, or is no longer the foreground target; stays stable through transient window-tracking misses; remains correctly layered during stats mouse passthrough; and opens over fullscreen mpv without switching Spaces.
|
- The overlay hides when mpv loses focus, is minimized, or is no longer the foreground target; stays stable through transient window-tracking misses; remains correctly layered during stats mouse passthrough; and opens over fullscreen mpv without switching Spaces.
|
||||||
- Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The compiled mpv window helper is now correctly bundled, preventing the overlay from falling back to a slower startup path on first launch.
|
- Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The compiled mpv window helper is now correctly bundled, preventing the overlay from falling back to a slower startup path on first launch.
|
||||||
|
- Yomitan popup focus is restored after card mining or popup reload; clicking transparent overlay space correctly closes the popup and returns passthrough to mpv without a hide/reappear cycle.
|
||||||
|
|
||||||
- **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player.
|
- **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player.
|
||||||
- The overlay stays stacked above mpv after click-to-focus events and is suspended while the in-player stats window is open.
|
- The overlay stays stacked above mpv after click-to-focus events and is suspended while the in-player stats window is open.
|
||||||
- Settings windows (SubMiner and Yomitan) now open above the subtitle overlay; the overlay hides immediately when the character dictionary modal opens, including while AniList lookup is in progress.
|
- Settings windows (SubMiner and Yomitan) now open above the subtitle overlay; the overlay hides immediately when the character dictionary modal opens, including while AniList lookup is in progress.
|
||||||
|
- Auto-paused startup is more reliable: the overlay stays interactive during the first measurement gap, startup subtitle cache misses paint raw text before tokenization finishes, and temporarily empty mpv subtitle reads are resolved before synthetic warm readiness can resume playback.
|
||||||
|
|
||||||
|
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
|
||||||
|
- The visible overlay and subtitle stream stay alive after restarting SubMiner from the `y-r` shortcut, with correct Linux bounds reapplication and user-paused playback preserved through readiness gates.
|
||||||
|
- The visible overlay remains active when mpv advances to the next playlist item, even when the next episode loads after the warm transition delay.
|
||||||
|
|
||||||
- **Jellyfin Playback:** Resolved a wide range of discovery and playback issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, and duplicate ready signals no longer re-show the overlay.
|
- **Jellyfin Playback:** Resolved a wide range of discovery and playback issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, and duplicate ready signals no longer re-show the overlay.
|
||||||
- Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track.
|
- Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track.
|
||||||
@@ -84,6 +96,7 @@
|
|||||||
- **AniList Progress:** Threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold.
|
- **AniList Progress:** Threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold.
|
||||||
- Season-specific results are preferred for multi-season files, with a clear message when the matched season is not in Planning or Watching status.
|
- Season-specific results are preferred for multi-season files, with a clear message when the matched season is not in Planning or Watching status.
|
||||||
- Repeated missing-token checks no longer exhaust AniList retry attempts or create duplicate dead-letter entries for the same episode.
|
- Repeated missing-token checks no longer exhaust AniList retry attempts or create duplicate dead-letter entries for the same episode.
|
||||||
|
- Manual AniList linking from the stats anime page now strips generated `Season N` suffixes from automatic searches so results match the base title correctly.
|
||||||
|
|
||||||
- **Anki:** Sentence-audio padding is now opt-in by default; animated AVIF freeze-frame duration is correctly aligned to word audio length without double-counting padding.
|
- **Anki:** Sentence-audio padding is now opt-in by default; animated AVIF freeze-frame duration is correctly aligned to word audio length without double-counting padding.
|
||||||
- Multi-line sentence mining stays aligned for repeated subtitle text; Kiku duplicate-card detection and merge flow are fixed; clipboard card updates from YouTube use mpv's resolved stream URLs; sentence cards refresh the secondary subtitle before saving.
|
- Multi-line sentence mining stays aligned for repeated subtitle text; Kiku duplicate-card detection and merge flow are fixed; clipboard card updates from YouTube use mpv's resolved stream URLs; sentence cards refresh the secondary subtitle before saving.
|
||||||
@@ -97,21 +110,19 @@
|
|||||||
|
|
||||||
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground.
|
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground.
|
||||||
- Builds that cannot apply native updates show a manual-install message instead of a restart prompt. Windows retains the native NSIS update path while routing updater HTTP through the main process.
|
- Builds that cannot apply native updates show a manual-install message instead of a restart prompt. Windows retains the native NSIS update path while routing updater HTTP through the main process.
|
||||||
|
- Linux updates now correctly create and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; both support assets are auto-installed from the bundled app on first launch if either is missing.
|
||||||
|
|
||||||
- **Setup - macOS:** First-run setup recognizes existing `subminer` installs in Homebrew or user PATH directories and avoids writing into Homebrew-owned paths.
|
- **Setup - macOS:** First-run setup recognizes existing `subminer` installs in Homebrew or user PATH directories and avoids writing into Homebrew-owned paths.
|
||||||
- `subminer app --setup` opens the setup flow even when SubMiner is already running. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed.
|
- `subminer app --setup` opens the setup flow even when SubMiner is already running. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed.
|
||||||
|
|
||||||
- **Tray App:** Fixed several lifecycle issues: the tray stays running when Yomitan settings are closed; a close-only menu prevents accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable.
|
- **Tray App:** Fixed several lifecycle issues: the tray stays running when Yomitan settings are closed; a close-only menu prevents accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable.
|
||||||
- Settings loading no longer blocks other tray actions; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized; the session help modal closes correctly without mpv running.
|
- Settings loading no longer blocks other tray actions; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized; the session help modal closes correctly without mpv running.
|
||||||
- On Windows, "Open SubMiner Setup" now correctly opens the setup window after first-run setup is complete.
|
- On Windows, "Open SubMiner Setup" now correctly opens the setup window after first-run setup is complete. System notifications now correctly show the SubMiner app icon when no custom notification image is provided.
|
||||||
|
|
||||||
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused until subtitle priming and tokenization readiness complete.
|
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused until subtitle priming and tokenization readiness complete.
|
||||||
- `subminer settings` on macOS no longer emits Electron menu diagnostics and exits cleanly when the window is closed. Linux first-run launcher installs build with a valid Bun shebang; `subminer app` on Linux returns control to the terminal immediately.
|
- `subminer settings` on macOS no longer emits Electron menu diagnostics and exits cleanly when the window is closed. Linux first-run launcher installs build with a valid Bun shebang; `subminer app` on Linux returns control to the terminal immediately.
|
||||||
- On Windows, managed mpv launches from a background instance correctly retarget the new mpv socket, bind to the player window, and receive startup overlay options.
|
- On Windows, managed mpv launches from a background instance correctly retarget the new mpv socket, bind to the player window, and receive startup overlay options.
|
||||||
|
|
||||||
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
|
|
||||||
- The visible overlay and subtitle stream stay alive after restarting SubMiner from the `y-r` shortcut, with correct Linux bounds reapplication and user-paused playback preserved through readiness gates.
|
|
||||||
|
|
||||||
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered. Annotations are corrected for Yomitan single-token compounds with internal particles like `目の前`.
|
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered. Annotations are corrected for Yomitan single-token compounds with internal particles like `目の前`.
|
||||||
|
|
||||||
- **Subtitle Annotation Prefetch:** Cached annotations and character images are ready for more live subtitle changes without delaying raw subtitle display.
|
- **Subtitle Annotation Prefetch:** Cached annotations and character images are ready for more live subtitle changes without delaying raw subtitle display.
|
||||||
@@ -143,6 +154,16 @@
|
|||||||
|
|
||||||
- **Architecture Docs:** Added a Playback Startup Flow diagram and a Runtime Sockets section and diagram to the IPC + Runtime Contracts page, with cross-reference pointers in the MPV Plugin and Troubleshooting pages.
|
- **Architecture Docs:** Added a Playback Startup Flow diagram and a Runtime Sockets section and diagram to the IPC + Runtime Contracts page, with cross-reference pointers in the MPV Plugin and Troubleshooting pages.
|
||||||
|
|
||||||
|
- **Linux Update Flow:** Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets if either one is missing.
|
||||||
|
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
- Replace subtitle delay actions with native mpv keybindings by @ksyasuda in #120
|
||||||
|
- fix(stats): strip Season N suffix from AniList title searches by @ksyasuda in #121
|
||||||
|
- fix(overlay): preserve visible state across playlist item transitions by @ksyasuda in #124
|
||||||
|
- fix(overlay): restore macOS Yomitan popup focus without breaking click-away by @ksyasuda in #125
|
||||||
|
- fix(linux): auto-install managed plugin copy; include in asset updates by @ksyasuda in #127
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
See the README and docs/installation guide for full setup steps.
|
See the README and docs/installation guide for full setup steps.
|
||||||
@@ -151,6 +172,7 @@ See the README and docs/installation guide for full setup steps.
|
|||||||
|
|
||||||
- Linux: `SubMiner.AppImage`
|
- Linux: `SubMiner.AppImage`
|
||||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||||
|
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||||
|
|
||||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ local function run_plugin_scenario(config)
|
|||||||
if name == "osd-height" then
|
if name == "osd-height" then
|
||||||
return config.osd_height or 720
|
return config.osd_height or 720
|
||||||
end
|
end
|
||||||
|
if name == "playlist-count" then
|
||||||
|
return config.playlist_count
|
||||||
|
end
|
||||||
|
if name == "playlist-pos" then
|
||||||
|
return config.playlist_pos
|
||||||
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -627,6 +633,46 @@ do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local scenario = {
|
||||||
|
process_list = "",
|
||||||
|
defer_timeouts = true,
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
auto_start_pause_until_ready = "yes",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
path = "/media/slow-episode-01.mkv",
|
||||||
|
media_title = "Slow Episode 1",
|
||||||
|
playlist_count = 2,
|
||||||
|
playlist_pos = 0,
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local recorded, err = run_plugin_scenario(scenario)
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for slow warm playlist visibility scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
recorded.script_messages["subminer-autoplay-ready"]()
|
||||||
|
fire_event(recorded, "end-file", { reason = "eof" })
|
||||||
|
fire_pending_timeouts(recorded)
|
||||||
|
scenario.path = "/media/slow-episode-02.mkv"
|
||||||
|
scenario.media_title = "Slow Episode 2"
|
||||||
|
scenario.playlist_pos = 1
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
assert_true(
|
||||||
|
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 0,
|
||||||
|
"slow playlist advance should preserve visible overlay state while the next episode is pending"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
count_start_calls(recorded.async_calls) == 1,
|
||||||
|
"slow playlist visibility reuse should not issue another --start command"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local scenario = {
|
local scenario = {
|
||||||
process_list = "",
|
process_list = "",
|
||||||
|
|||||||
@@ -69,6 +69,25 @@ test('parseArgs captures update command and internal launcher paths', () => {
|
|||||||
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs captures hidden Linux runtime plugin asset ensure command', () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
'--ensure-linux-runtime-plugin-assets',
|
||||||
|
'--ensure-linux-runtime-plugin-assets-response-path',
|
||||||
|
'/tmp/subminer-plugin-response.json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.ensureLinuxRuntimePluginAssets, true);
|
||||||
|
assert.equal(
|
||||||
|
args.ensureLinuxRuntimePluginAssetsResponsePath,
|
||||||
|
'/tmp/subminer-plugin-response.json',
|
||||||
|
);
|
||||||
|
assert.equal(hasExplicitCommand(args), true);
|
||||||
|
assert.equal(shouldStartApp(args), true);
|
||||||
|
assert.equal(isHeadlessInitialCommand(args), true);
|
||||||
|
assert.equal(commandNeedsOverlayRuntime(args), false);
|
||||||
|
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
|
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
|
||||||
const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']);
|
const args = parseArgs(['--launch-mpv', 'C:\\a.mkv', 'C:\\b.mkv']);
|
||||||
assert.equal(args.launchMpv, true);
|
assert.equal(args.launchMpv, true);
|
||||||
|
|||||||
+21
-3
@@ -80,6 +80,8 @@ export interface CliArgs {
|
|||||||
update?: boolean;
|
update?: boolean;
|
||||||
updateLauncherPath?: string;
|
updateLauncherPath?: string;
|
||||||
updateResponsePath?: string;
|
updateResponsePath?: string;
|
||||||
|
ensureLinuxRuntimePluginAssets?: boolean;
|
||||||
|
ensureLinuxRuntimePluginAssetsResponsePath?: string;
|
||||||
autoStartOverlay: boolean;
|
autoStartOverlay: boolean;
|
||||||
generateConfig: boolean;
|
generateConfig: boolean;
|
||||||
configPath?: string;
|
configPath?: string;
|
||||||
@@ -178,6 +180,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
update: false,
|
update: false,
|
||||||
updateLauncherPath: undefined,
|
updateLauncherPath: undefined,
|
||||||
updateResponsePath: undefined,
|
updateResponsePath: undefined,
|
||||||
|
ensureLinuxRuntimePluginAssets: false,
|
||||||
|
ensureLinuxRuntimePluginAssetsResponsePath: undefined,
|
||||||
autoStartOverlay: false,
|
autoStartOverlay: false,
|
||||||
generateConfig: false,
|
generateConfig: false,
|
||||||
backupOverwrite: false,
|
backupOverwrite: false,
|
||||||
@@ -379,7 +383,15 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
|
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
|
||||||
else if (arg === '--app-ping') args.appPing = true;
|
else if (arg === '--app-ping') args.appPing = true;
|
||||||
else if (arg === '--update') args.update = true;
|
else if (arg === '--update') args.update = true;
|
||||||
else if (arg.startsWith('--update-launcher-path=')) {
|
else if (arg === '--ensure-linux-runtime-plugin-assets') {
|
||||||
|
args.ensureLinuxRuntimePluginAssets = true;
|
||||||
|
} else if (arg.startsWith('--ensure-linux-runtime-plugin-assets-response-path=')) {
|
||||||
|
const value = arg.split('=', 2)[1];
|
||||||
|
if (value) args.ensureLinuxRuntimePluginAssetsResponsePath = value;
|
||||||
|
} else if (arg === '--ensure-linux-runtime-plugin-assets-response-path') {
|
||||||
|
const value = readValue(argv[i + 1]);
|
||||||
|
if (value) args.ensureLinuxRuntimePluginAssetsResponsePath = value;
|
||||||
|
} else if (arg.startsWith('--update-launcher-path=')) {
|
||||||
const value = arg.split('=', 2)[1];
|
const value = arg.split('=', 2)[1];
|
||||||
if (value) args.updateLauncherPath = value;
|
if (value) args.updateLauncherPath = value;
|
||||||
} else if (arg === '--update-launcher-path') {
|
} else if (arg === '--update-launcher-path') {
|
||||||
@@ -581,13 +593,16 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.texthooker ||
|
args.texthooker ||
|
||||||
args.appPing ||
|
args.appPing ||
|
||||||
args.update ||
|
args.update ||
|
||||||
|
args.ensureLinuxRuntimePluginAssets === true ||
|
||||||
args.generateConfig ||
|
args.generateConfig ||
|
||||||
args.help
|
args.help
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isHeadlessInitialCommand(args: CliArgs): boolean {
|
export function isHeadlessInitialCommand(args: CliArgs): boolean {
|
||||||
return args.refreshKnownWords || args.update === true;
|
return (
|
||||||
|
args.refreshKnownWords || args.update === true || args.ensureLinuxRuntimePluginAssets === true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||||
@@ -654,6 +669,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
!args.jellyfinPreviewAuth &&
|
!args.jellyfinPreviewAuth &&
|
||||||
!args.appPing &&
|
!args.appPing &&
|
||||||
!args.update &&
|
!args.update &&
|
||||||
|
!args.ensureLinuxRuntimePluginAssets &&
|
||||||
!args.help &&
|
!args.help &&
|
||||||
!args.autoStartOverlay &&
|
!args.autoStartOverlay &&
|
||||||
!args.generateConfig
|
!args.generateConfig
|
||||||
@@ -707,7 +723,8 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.jellyfin ||
|
args.jellyfin ||
|
||||||
args.jellyfinPlay ||
|
args.jellyfinPlay ||
|
||||||
args.texthooker ||
|
args.texthooker ||
|
||||||
args.update
|
args.update ||
|
||||||
|
args.ensureLinuxRuntimePluginAssets
|
||||||
) {
|
) {
|
||||||
if (args.launchMpv) {
|
if (args.launchMpv) {
|
||||||
return false;
|
return false;
|
||||||
@@ -780,6 +797,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.texthooker &&
|
!args.texthooker &&
|
||||||
!args.appPing &&
|
!args.appPing &&
|
||||||
!args.update &&
|
!args.update &&
|
||||||
|
!args.ensureLinuxRuntimePluginAssets &&
|
||||||
!args.help &&
|
!args.help &&
|
||||||
!args.autoStartOverlay &&
|
!args.autoStartOverlay &&
|
||||||
!args.generateConfig &&
|
!args.generateConfig &&
|
||||||
|
|||||||
@@ -243,6 +243,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
runUpdateCommand: async (args) => {
|
runUpdateCommand: async (args) => {
|
||||||
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
|
calls.push(`runUpdateCommand:${args.updateLauncherPath ?? ''}`);
|
||||||
},
|
},
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
|
||||||
|
calls.push('runEnsureLinuxRuntimePluginAssetsCommand');
|
||||||
|
},
|
||||||
printHelp: () => {
|
printHelp: () => {
|
||||||
calls.push('printHelp');
|
calls.push('printHelp');
|
||||||
},
|
},
|
||||||
@@ -624,6 +627,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
|||||||
stop: () => {},
|
stop: () => {},
|
||||||
hasMainWindow: () => true,
|
hasMainWindow: () => true,
|
||||||
runUpdateCommand: async () => {},
|
runUpdateCommand: async () => {},
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||||
runYoutubePlaybackFlow: async () => {},
|
runYoutubePlaybackFlow: async () => {},
|
||||||
},
|
},
|
||||||
dispatchSessionAction: async () => {},
|
dispatchSessionAction: async () => {},
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ export interface CliCommandServiceDeps {
|
|||||||
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
runUpdateCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: (
|
||||||
|
args: CliArgs,
|
||||||
|
source: CliCommandSource,
|
||||||
|
) => Promise<void>;
|
||||||
runYoutubePlaybackFlow: (request: {
|
runYoutubePlaybackFlow: (request: {
|
||||||
url: string;
|
url: string;
|
||||||
mode: NonNullable<CliArgs['youtubeMode']>;
|
mode: NonNullable<CliArgs['youtubeMode']>;
|
||||||
@@ -182,6 +186,7 @@ interface AppCliRuntime {
|
|||||||
stop: () => void;
|
stop: () => void;
|
||||||
hasMainWindow: () => boolean;
|
hasMainWindow: () => boolean;
|
||||||
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
runUpdateCommand: CliCommandServiceDeps['runUpdateCommand'];
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandServiceDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +297,7 @@ export function createCliCommandDepsRuntime(
|
|||||||
runStatsCommand: options.jellyfin.runStatsCommand,
|
runStatsCommand: options.jellyfin.runStatsCommand,
|
||||||
runJellyfinCommand: options.jellyfin.runCommand,
|
runJellyfinCommand: options.jellyfin.runCommand,
|
||||||
runUpdateCommand: options.app.runUpdateCommand,
|
runUpdateCommand: options.app.runUpdateCommand,
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: options.app.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
|
||||||
printHelp: options.ui.printHelp,
|
printHelp: options.ui.printHelp,
|
||||||
hasMainWindow: options.app.hasMainWindow,
|
hasMainWindow: options.app.hasMainWindow,
|
||||||
@@ -454,6 +460,19 @@ export function handleCliCommand(
|
|||||||
deps.stopApp();
|
deps.stopApp();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else if (args.ensureLinuxRuntimePluginAssets) {
|
||||||
|
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
|
||||||
|
deps
|
||||||
|
.runEnsureLinuxRuntimePluginAssetsCommand(args, source)
|
||||||
|
.catch((err) => {
|
||||||
|
deps.error('runEnsureLinuxRuntimePluginAssetsCommand failed:', err);
|
||||||
|
deps.showMpvOsd(`Linux runtime plugin install failed: ${(err as Error).message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (shouldStopAfterRun) {
|
||||||
|
deps.stopApp();
|
||||||
|
}
|
||||||
|
});
|
||||||
} else if (args.toggleSecondarySub) {
|
} else if (args.toggleSecondarySub) {
|
||||||
deps.cycleSecondarySubMode();
|
deps.cycleSecondarySubMode();
|
||||||
} else if (args.triggerFieldGrouping) {
|
} else if (args.triggerFieldGrouping) {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface MpvRuntimeClientLike {
|
|||||||
playNextSubtitle?: () => void;
|
playNextSubtitle?: () => void;
|
||||||
setSubVisibility?: (visible: boolean) => void;
|
setSubVisibility?: (visible: boolean) => void;
|
||||||
setSecondarySubVisibility?: (visible: boolean) => void;
|
setSecondarySubVisibility?: (visible: boolean) => void;
|
||||||
|
setCurrentSecondarySubText?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showMpvOsdRuntime(
|
export function showMpvOsdRuntime(
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { resolveDefaultNotificationIconPath } from './notification';
|
||||||
|
|
||||||
|
test('default notification icon resolves packaged SubMiner asset when no per-notification icon is provided', () => {
|
||||||
|
const path = resolveDefaultNotificationIconPath({
|
||||||
|
platform: 'linux',
|
||||||
|
resourcesPath: '/opt/SubMiner/resources',
|
||||||
|
appPath: '/opt/SubMiner/resources/app.asar',
|
||||||
|
dirname: '/opt/SubMiner/resources/app.asar/dist/core/utils',
|
||||||
|
cwd: '/opt/SubMiner',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
fileExists: (candidate) => candidate === '/opt/SubMiner/resources/assets/SubMiner.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path, '/opt/SubMiner/resources/assets/SubMiner.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default notification icon prefers the square app icon when bundled images are available', () => {
|
||||||
|
const path = resolveDefaultNotificationIconPath({
|
||||||
|
platform: 'linux',
|
||||||
|
resourcesPath: '/opt/SubMiner/resources',
|
||||||
|
appPath: '/opt/SubMiner/resources/app.asar',
|
||||||
|
dirname: '/opt/SubMiner/resources/app.asar/dist/core/utils',
|
||||||
|
cwd: '/opt/SubMiner',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
fileExists: (candidate) =>
|
||||||
|
candidate === '/opt/SubMiner/resources/assets/SubMiner.png' ||
|
||||||
|
candidate === '/opt/SubMiner/resources/assets/SubMiner-square.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path, '/opt/SubMiner/resources/assets/SubMiner-square.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default notification icon avoids macOS tray template assets', () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const path = resolveDefaultNotificationIconPath({
|
||||||
|
platform: 'darwin',
|
||||||
|
resourcesPath: '/Applications/SubMiner.app/Contents/Resources',
|
||||||
|
appPath: '/Applications/SubMiner.app/Contents/Resources/app.asar',
|
||||||
|
dirname: '/Applications/SubMiner.app/Contents/Resources/app.asar/dist/core/utils',
|
||||||
|
cwd: '/Applications/SubMiner.app/Contents/Resources',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
fileExists: (candidate) => {
|
||||||
|
seen.push(candidate);
|
||||||
|
return candidate.endsWith('/assets/SubMiner-square.png');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(path, '/Applications/SubMiner.app/Contents/Resources/assets/SubMiner-square.png');
|
||||||
|
assert.equal(
|
||||||
|
seen.some((candidate) => candidate.includes('SubMinerTemplate')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default notification icon resolves cwd fallback through injected deps', () => {
|
||||||
|
const resolvedPath = resolveDefaultNotificationIconPath({
|
||||||
|
platform: 'linux',
|
||||||
|
resourcesPath: '/missing/resources',
|
||||||
|
appPath: '/missing/app',
|
||||||
|
dirname: '/missing/dist/core/utils',
|
||||||
|
cwd: '/portable/SubMiner',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
fileExists: (candidate) => candidate === '/portable/SubMiner/assets/SubMiner-square.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolvedPath, '/portable/SubMiner/assets/SubMiner-square.png');
|
||||||
|
});
|
||||||
@@ -1,10 +1,57 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
|
||||||
const { Notification, nativeImage } = electron;
|
const { Notification, nativeImage } = electron;
|
||||||
const logger = createLogger('core:notification');
|
const logger = createLogger('core:notification');
|
||||||
|
|
||||||
|
export function resolveDefaultNotificationIconPath(deps: {
|
||||||
|
platform: string;
|
||||||
|
resourcesPath: string;
|
||||||
|
appPath: string;
|
||||||
|
dirname: string;
|
||||||
|
cwd: string;
|
||||||
|
joinPath: (...parts: string[]) => string;
|
||||||
|
fileExists: (path: string) => boolean;
|
||||||
|
}): string | null {
|
||||||
|
const iconNames =
|
||||||
|
deps.platform === 'win32'
|
||||||
|
? ['SubMiner.ico', 'SubMiner-square.png', 'SubMiner.png']
|
||||||
|
: ['SubMiner-square.png', 'SubMiner.png'];
|
||||||
|
|
||||||
|
const baseDirs = [
|
||||||
|
deps.joinPath(deps.resourcesPath, 'assets'),
|
||||||
|
deps.joinPath(deps.appPath, 'assets'),
|
||||||
|
deps.joinPath(deps.dirname, '..', 'assets'),
|
||||||
|
deps.joinPath(deps.dirname, '..', '..', 'assets'),
|
||||||
|
deps.joinPath(deps.cwd, 'assets'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const baseDir of baseDirs) {
|
||||||
|
for (const iconName of iconNames) {
|
||||||
|
const candidate = deps.joinPath(baseDir, iconName);
|
||||||
|
if (deps.fileExists(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeDefaultNotificationIconPath(): string | null {
|
||||||
|
return resolveDefaultNotificationIconPath({
|
||||||
|
platform: process.platform,
|
||||||
|
resourcesPath: process.resourcesPath,
|
||||||
|
appPath: electron.app?.getAppPath?.() ?? process.cwd(),
|
||||||
|
dirname: __dirname,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
joinPath: (...parts) => path.join(...parts),
|
||||||
|
fileExists: (candidate) => fs.existsSync(candidate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function showDesktopNotification(
|
export function showDesktopNotification(
|
||||||
title: string,
|
title: string,
|
||||||
options: { body?: string; icon?: string },
|
options: { body?: string; icon?: string },
|
||||||
@@ -19,19 +66,20 @@ export function showDesktopNotification(
|
|||||||
notificationOptions.body = options.body;
|
notificationOptions.body = options.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.icon) {
|
const icon = options.icon ?? resolveRuntimeDefaultNotificationIconPath() ?? undefined;
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
const isFilePath =
|
const isFilePath =
|
||||||
typeof options.icon === 'string' &&
|
typeof icon === 'string' && (icon.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(icon));
|
||||||
(options.icon.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(options.icon));
|
|
||||||
|
|
||||||
if (isFilePath) {
|
if (isFilePath) {
|
||||||
if (fs.existsSync(options.icon)) {
|
if (fs.existsSync(icon)) {
|
||||||
notificationOptions.icon = options.icon;
|
notificationOptions.icon = icon;
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Notification icon file not found', options.icon);
|
logger.warn('Notification icon file not found', icon);
|
||||||
}
|
}
|
||||||
} else if (typeof options.icon === 'string' && options.icon.startsWith('data:image/')) {
|
} else if (typeof icon === 'string' && icon.startsWith('data:image/')) {
|
||||||
const base64Data = options.icon.replace(/^data:image\/\w+;base64,/, '');
|
const base64Data = icon.replace(/^data:image\/\w+;base64,/, '');
|
||||||
try {
|
try {
|
||||||
const image = nativeImage.createFromBuffer(Buffer.from(base64Data, 'base64'));
|
const image = nativeImage.createFromBuffer(Buffer.from(base64Data, 'base64'));
|
||||||
if (image.isEmpty()) {
|
if (image.isEmpty()) {
|
||||||
@@ -45,7 +93,7 @@ export function showDesktopNotification(
|
|||||||
logger.error('Failed to create notification icon from base64', err);
|
logger.error('Failed to create notification icon from base64', err);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
notificationOptions.icon = options.icon;
|
notificationOptions.icon = icon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+410
-1973
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,7 @@ export interface CliCommandRuntimeServiceContext {
|
|||||||
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
|
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
|
||||||
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
|
||||||
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
|
runUpdateCommand: CliCommandRuntimeServiceDepsParams['app']['runUpdateCommand'];
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceDepsParams['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandRuntimeServiceDepsParams['app']['runYoutubePlaybackFlow'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
@@ -124,6 +125,7 @@ function createCliCommandDepsFromContext(
|
|||||||
stop: context.stopApp,
|
stop: context.stopApp,
|
||||||
hasMainWindow: context.hasMainWindow,
|
hasMainWindow: context.hasMainWindow,
|
||||||
runUpdateCommand: context.runUpdateCommand,
|
runUpdateCommand: context.runUpdateCommand,
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: context.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
|
||||||
},
|
},
|
||||||
dispatchSessionAction: context.dispatchSessionAction,
|
dispatchSessionAction: context.dispatchSessionAction,
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export interface CliCommandRuntimeServiceDepsParams {
|
|||||||
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
stop: CliCommandDepsRuntimeOptions['app']['stop'];
|
||||||
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
|
||||||
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
|
runUpdateCommand: CliCommandDepsRuntimeOptions['app']['runUpdateCommand'];
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandDepsRuntimeOptions['app']['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
|
||||||
};
|
};
|
||||||
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
|
dispatchSessionAction: CliCommandDepsRuntimeOptions['dispatchSessionAction'];
|
||||||
@@ -392,6 +393,7 @@ export function createCliCommandRuntimeServiceDeps(
|
|||||||
stop: params.app.stop,
|
stop: params.app.stop,
|
||||||
hasMainWindow: params.app.hasMainWindow,
|
hasMainWindow: params.app.hasMainWindow,
|
||||||
runUpdateCommand: params.app.runUpdateCommand,
|
runUpdateCommand: params.app.runUpdateCommand,
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: params.app.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
|
||||||
},
|
},
|
||||||
dispatchSessionAction: params.dispatchSessionAction,
|
dispatchSessionAction: params.dispatchSessionAction,
|
||||||
|
|||||||
+124
-30
@@ -7,6 +7,10 @@ function readMainSource(): string {
|
|||||||
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
|
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSource(relPath: string): string {
|
||||||
|
return fs.readFileSync(path.join(process.cwd(), relPath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
test('manual watched session action starts immersion tracker before marking watched', () => {
|
test('manual watched session action starts immersion tracker before marking watched', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
@@ -91,15 +95,15 @@ test('mpv startup signals start overlay loading OSD before readiness work', () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
|
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
|
||||||
const source = readMainSource();
|
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||||
const dismissBlock = source.match(
|
const dismissBlock = source.match(
|
||||||
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(dismissBlock);
|
assert.ok(dismissBlock);
|
||||||
assert.match(
|
assert.match(
|
||||||
dismissBlock,
|
dismissBlock,
|
||||||
/sendMpvCommandRuntime\(appState\.mpvClient, \['script-message', 'subminer-overlay-loading-ready'\]\);/,
|
/sendMpvCommandRuntime\(deps\.getMpvClient\(\), \[\s*'script-message',\s*'subminer-overlay-loading-ready',\s*\]\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,9 +150,9 @@ test('all visible overlay hide paths clear stale overlay input state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
||||||
const source = readMainSource();
|
const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n\}/,
|
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -157,13 +161,14 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
|
|||||||
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
|
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') <
|
actionBlock.indexOf('deps.initSubtitlePrefetch(') <
|
||||||
actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'),
|
actionBlock.indexOf('deps.setActiveParsedSubtitleMediaPath(nextMediaPath);'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('update overlay notification action triggers install flow', () => {
|
test('update overlay notification action triggers install flow', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
|
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
@@ -173,13 +178,16 @@ test('update overlay notification action triggers install flow', () => {
|
|||||||
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
|
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
|
||||||
assert.match(source, /installWhenAvailable:\s*true/);
|
assert.match(source, /installWhenAvailable:\s*true/);
|
||||||
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
|
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
|
||||||
assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/);
|
assert.match(runtimeSource, /deps\.getAnkiIntegration\(\)\?\.openNoteInAnki\(noteId\)/);
|
||||||
assert.match(source, /appState\.runtimeOptionsManager\?\.getEffectiveAnkiConnectConfig/);
|
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
runtimeSource,
|
||||||
|
/deps\.getRuntimeOptionsManager\(\)\?\.getEffectiveAnkiConnectConfig/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
runtimeSource,
|
||||||
/new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/,
|
/new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/,
|
||||||
);
|
);
|
||||||
assert.match(source, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
assert.match(runtimeSource, /fallbackClient\.openNoteInBrowser\(noteId\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
|
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
|
||||||
@@ -203,9 +211,9 @@ test('subtitle change re-prioritizes prefetch around live playback before tokeni
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => {
|
test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => {
|
||||||
const source = readMainSource();
|
const source = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n\}/,
|
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -224,6 +232,31 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('autoplay subtitle prime reuses active parsed cues before synthetic warm release', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const runtimeDepsBlock = source.match(
|
||||||
|
/const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const primeSource = readSource('src/main/runtime/autoplay-subtitle-priming-runtime.ts');
|
||||||
|
const emptyTextBlock = primeSource.match(
|
||||||
|
/if \(!text\.trim\(\) && isCurrentAutoplayMediaPath\(mediaPath\)\) \{(?<body>[\s\S]*?)\n \}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(runtimeDepsBlock);
|
||||||
|
assert.match(
|
||||||
|
runtimeDepsBlock,
|
||||||
|
/getActiveParsedSubtitleCues:\s*\(\) => appState\.activeParsedSubtitleCues/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(emptyTextBlock);
|
||||||
|
assert.ok(
|
||||||
|
emptyTextBlock.indexOf('await deps.refreshSubtitlePrefetchFromActiveTrack()') <
|
||||||
|
emptyTextBlock.indexOf(
|
||||||
|
'await primeAutoplaySubtitleFromParsedCues(mediaPath, deps.getActiveParsedSubtitleCues())',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('startup autoplay release is tied to visible overlay measurement readiness', () => {
|
test('startup autoplay release is tied to visible overlay measurement readiness', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const gateBlock = source.match(
|
const gateBlock = source.match(
|
||||||
@@ -346,18 +379,18 @@ test('warm tokenization release can signal readiness before the first subtitle a
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
||||||
const source = readMainSource();
|
const source = readSource('src/main/runtime/stats-server-runtime.ts');
|
||||||
const startStatsServerBlock = source.match(
|
const startStatsServerBlock = source.match(
|
||||||
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
||||||
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(addYomitanNoteBlock);
|
assert.ok(addYomitanNoteBlock);
|
||||||
assert.match(
|
assert.match(
|
||||||
addYomitanNoteBlock,
|
addYomitanNoteBlock,
|
||||||
/const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/,
|
/const ankiConnectConfig = deps\.getResolvedConfig\(\)\.ankiConnect;/,
|
||||||
);
|
);
|
||||||
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
||||||
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
||||||
@@ -365,11 +398,12 @@ test('stats server Yomitan note creation honors configured Anki server override
|
|||||||
|
|
||||||
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
|
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const resetBlock = source.match(
|
const resetBlock = runtimeSource.match(
|
||||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -459,17 +493,17 @@ test('manual visible overlay hide dismisses loading OSD', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('configured overlay notifications require visible ready overlay window', () => {
|
test('configured overlay notifications require visible ready overlay window', () => {
|
||||||
const source = readMainSource();
|
const source = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||||
const readinessBlock = source.match(
|
const readinessBlock = source.match(
|
||||||
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n\}/,
|
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const statusBlock = source.match(
|
const statusBlock = source.match(
|
||||||
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(readinessBlock);
|
assert.ok(readinessBlock);
|
||||||
assert.ok(statusBlock);
|
assert.ok(statusBlock);
|
||||||
assert.match(readinessBlock, /overlayManager\.getVisibleOverlayVisible\(\)/);
|
assert.match(readinessBlock, /deps\.getVisibleOverlayVisible\(\)/);
|
||||||
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
|
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
|
||||||
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
|
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
|
||||||
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
|
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
|
||||||
@@ -488,18 +522,19 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
|
|||||||
assert.ok(toggleBlock);
|
assert.ok(toggleBlock);
|
||||||
assert.match(
|
assert.match(
|
||||||
setBlock,
|
setBlock,
|
||||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
toggleBlock,
|
toggleBlock,
|
||||||
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
|
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const resetBlock = source.match(
|
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||||
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
const resetBlock = runtimeSource.match(
|
||||||
|
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const setBlock = source.match(
|
const setBlock = source.match(
|
||||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
@@ -509,16 +544,75 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
|
|||||||
assert.ok(setBlock);
|
assert.ok(setBlock);
|
||||||
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
|
||||||
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||||
|
assert.doesNotMatch(runtimeSource, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
setBlock,
|
setBlock,
|
||||||
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+startLinuxVisibleOverlayStartupInputGrace\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
|
test('Linux visible overlay startup reapplies passive passthrough after input reset', () => {
|
||||||
|
const pointerSource = readSource('src/main/runtime/linux-overlay-pointer-interaction.ts');
|
||||||
|
const runtimeSource = readSource('src/main/runtime/visible-overlay-interaction-runtime.ts');
|
||||||
|
const resetPrimerBlock = runtimeSource.match(
|
||||||
|
/function resetLinuxVisibleOverlayStartupInputPrimer\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const startGraceBlock = runtimeSource.match(
|
||||||
|
/function startLinuxVisibleOverlayStartupInputGrace\(\): void \{(?<body>[\s\S]*?)\n \}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const depsBlock = runtimeSource.match(
|
||||||
|
/const linuxOverlayPointerInteractionDeps = \{(?<body>[\s\S]*?)\n \};/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(resetPrimerBlock);
|
||||||
|
assert.ok(startGraceBlock);
|
||||||
|
assert.ok(depsBlock);
|
||||||
|
assert.match(resetPrimerBlock, /visibleOverlayInteractionActive = false;/);
|
||||||
|
assert.match(resetPrimerBlock, /linuxOverlayPointerInteractionStateApplied = false;/);
|
||||||
|
assert.match(
|
||||||
|
startGraceBlock,
|
||||||
|
/linuxVisibleOverlayStartupInputGraceUntilMs =\s+Date\.now\(\) \+ LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;/,
|
||||||
|
);
|
||||||
|
assert.match(startGraceBlock, /linuxOverlayPointerInteractionStateApplied = false;/);
|
||||||
|
assert.match(
|
||||||
|
depsBlock,
|
||||||
|
/isInteractionStateApplied:\s*\(\) => linuxOverlayPointerInteractionStateApplied/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
pointerSource,
|
||||||
|
/deps\.getInteractionActive\(\) === desired && deps\.isInteractionStateApplied\?\.\(\) !== false/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Linux visible overlay show starts input grace before first measurement', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
|
const setVisibleBlock = source.match(
|
||||||
|
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const toggleBlock = source.match(
|
||||||
|
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
const setOverlayBlock = source.match(
|
||||||
|
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
for (const block of [setVisibleBlock, toggleBlock, setOverlayBlock]) {
|
||||||
|
assert.ok(block);
|
||||||
|
assert.ok(
|
||||||
|
block.indexOf('resetLinuxVisibleOverlayStartupInputPrimer();') <
|
||||||
|
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();'),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
block.indexOf('startLinuxVisibleOverlayStartupInputGrace();') <
|
||||||
|
block.indexOf('void primeCurrentSubtitleForVisibleOverlay();'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
|
||||||
|
const source = readSource('src/main/runtime/overlay-geometry-runtime.ts');
|
||||||
const afterBoundsBlock = source.match(
|
const afterBoundsBlock = source.match(
|
||||||
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
|
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(afterBoundsBlock);
|
assert.ok(afterBoundsBlock);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { getPasswordStoreArg } from './password-store-args';
|
||||||
|
|
||||||
|
test('getPasswordStoreArg ignores split-form whitespace-only values', () => {
|
||||||
|
assert.equal(getPasswordStoreArg(['SubMiner.AppImage', '--password-store', ' ']), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const PASSWORD_STORE_ARG = '--password-store';
|
||||||
|
const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret';
|
||||||
|
|
||||||
|
export function getPasswordStoreArg(argv: string[]): string | null {
|
||||||
|
let resolved: string | null = null;
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (!arg?.startsWith(PASSWORD_STORE_ARG)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === PASSWORD_STORE_ARG) {
|
||||||
|
const value = argv[i + 1];
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (trimmed && !trimmed.startsWith('--')) {
|
||||||
|
resolved = trimmed;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [prefix, value] = arg.split('=', 2);
|
||||||
|
if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) {
|
||||||
|
resolved = value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePasswordStoreArg(value: string): string {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized.toLowerCase() === 'gnome') {
|
||||||
|
return DEFAULT_LINUX_PASSWORD_STORE;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultPasswordStore(): string {
|
||||||
|
return DEFAULT_LINUX_PASSWORD_STORE;
|
||||||
|
}
|
||||||
@@ -137,6 +137,46 @@ test('autoplay ready gate requests overlay pointer recovery when media readiness
|
|||||||
assert.equal(pointerRecoveryRequests, 1);
|
assert.equal(pointerRecoveryRequests, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('autoplay ready gate reports the released autoplay signal once', async () => {
|
||||||
|
const releasedSignals: string[] = [];
|
||||||
|
|
||||||
|
const gate = createAutoplayReadyGate({
|
||||||
|
isAppOwnedFlowInFlight: () => false,
|
||||||
|
getCurrentMediaPath: () => '/media/video.mkv',
|
||||||
|
getCurrentVideoPath: () => null,
|
||||||
|
getPlaybackPaused: () => true,
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
connected: true,
|
||||||
|
requestProperty: async () => true,
|
||||||
|
send: () => {},
|
||||||
|
}) as never,
|
||||||
|
signalPluginAutoplayReady: () => {},
|
||||||
|
onAutoplayReadyReleased: (signal) => {
|
||||||
|
releasedSignals.push(signal.payload.text);
|
||||||
|
},
|
||||||
|
schedule: (callback) => {
|
||||||
|
queueMicrotask(callback);
|
||||||
|
return 1 as never;
|
||||||
|
},
|
||||||
|
logDebug: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
gate.maybeSignalPluginAutoplayReady(
|
||||||
|
{ text: '__warm__', tokens: null },
|
||||||
|
{ forceWhilePaused: true },
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
gate.maybeSignalPluginAutoplayReady(
|
||||||
|
{ text: '次の字幕', tokens: null },
|
||||||
|
{ forceWhilePaused: true },
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.deepEqual(releasedSignals, ['__warm__']);
|
||||||
|
});
|
||||||
|
|
||||||
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
|
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
|
||||||
const commands: Array<Array<string | boolean>> = [];
|
const commands: Array<Array<string | boolean>> = [];
|
||||||
let playbackPaused = true;
|
let playbackPaused = true;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type AutoplayReadyGateDeps = {
|
|||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
signalPluginAutoplayReady: () => void;
|
signalPluginAutoplayReady: () => void;
|
||||||
requestOverlayPointerRecovery?: () => void;
|
requestOverlayPointerRecovery?: () => void;
|
||||||
|
onAutoplayReadyReleased?: (signal: AutoplayReadySignal) => void;
|
||||||
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
@@ -182,6 +183,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||||
deps.signalPluginAutoplayReady();
|
deps.signalPluginAutoplayReady();
|
||||||
deps.requestOverlayPointerRecovery?.();
|
deps.requestOverlayPointerRecovery?.();
|
||||||
|
deps.onAutoplayReadyReleased?.(signal);
|
||||||
attemptRelease(playbackGeneration, 0);
|
attemptRelease(playbackGeneration, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
createAutoplaySubtitlePrimingRuntime,
|
||||||
|
setMpvCurrentSecondarySubText,
|
||||||
|
} from './autoplay-subtitle-priming-runtime';
|
||||||
|
|
||||||
|
test('setMpvCurrentSecondarySubText uses client setter when available', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const client = {
|
||||||
|
currentSecondarySubText: '',
|
||||||
|
setCurrentSecondarySubText: (text: string) => {
|
||||||
|
calls.push(text);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setMpvCurrentSecondarySubText(client, 'secondary');
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['secondary']);
|
||||||
|
assert.equal(client.currentSecondarySubText, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setMpvCurrentSecondarySubText updates client property when setter is unavailable', () => {
|
||||||
|
const client = {
|
||||||
|
currentSecondarySubText: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
setMpvCurrentSecondarySubText(client, 'secondary');
|
||||||
|
|
||||||
|
assert.equal(client.currentSecondarySubText, 'secondary');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduleSubtitlePrefetchRefresh logs refresh failures from timer callback', async () => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const runtime = createAutoplaySubtitlePrimingRuntime({
|
||||||
|
getCurrentMediaPath: () => null,
|
||||||
|
getMpvClient: () => null,
|
||||||
|
setCurrentSubText: () => {},
|
||||||
|
getCurrentSubText: () => '',
|
||||||
|
getCurrentSubtitleData: () => null,
|
||||||
|
getActiveParsedSubtitleCues: () => [],
|
||||||
|
setActiveParsedSubtitleMediaPath: () => {},
|
||||||
|
subtitleProcessingController: {
|
||||||
|
consumeCachedSubtitle: () => null,
|
||||||
|
onSubtitleChange: () => {},
|
||||||
|
refreshCurrentSubtitle: () => {},
|
||||||
|
},
|
||||||
|
emitSubtitlePayload: () => {},
|
||||||
|
getSubtitlePrefetchService: () => null,
|
||||||
|
getLastObservedTimePos: () => 0,
|
||||||
|
getVisibleOverlayVisible: () => false,
|
||||||
|
emitSecondarySubtitle: () => {},
|
||||||
|
initSubtitlePrefetch: async () => {},
|
||||||
|
refreshSubtitlePrefetchFromActiveTrack: async () => {
|
||||||
|
throw new Error('refresh failed');
|
||||||
|
},
|
||||||
|
logDebug: (message) => logs.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.scheduleSubtitlePrefetchRefresh(0);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
|
||||||
|
assert.deepEqual(logs, [
|
||||||
|
'[autoplay-subtitle-prime] subtitle prefetch refresh failed: refresh failed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('primeCurrentSubtitleForAutoplay refreshes active subtitle cues when mpv sub-text is empty', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let currentSubText = '';
|
||||||
|
let activeParsedSubtitleCues: Array<{ startTime: number; endTime: number; text: string }> = [];
|
||||||
|
const mediaPath = '/media/video.mkv';
|
||||||
|
|
||||||
|
const runtime = createAutoplaySubtitlePrimingRuntime({
|
||||||
|
getCurrentMediaPath: () => mediaPath,
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentVideoPath: mediaPath,
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
calls.push(`request:${name}`);
|
||||||
|
if (name === 'sub-text') return '';
|
||||||
|
if (name === 'time-pos') return 12;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setCurrentSubText: (text) => {
|
||||||
|
currentSubText = text;
|
||||||
|
calls.push(`set:${text}`);
|
||||||
|
},
|
||||||
|
getCurrentSubText: () => currentSubText,
|
||||||
|
getCurrentSubtitleData: () => null,
|
||||||
|
getActiveParsedSubtitleCues: () => activeParsedSubtitleCues,
|
||||||
|
setActiveParsedSubtitleMediaPath: () => {},
|
||||||
|
subtitleProcessingController: {
|
||||||
|
consumeCachedSubtitle: () => null,
|
||||||
|
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||||
|
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
|
||||||
|
},
|
||||||
|
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
|
||||||
|
getSubtitlePrefetchService: () => ({
|
||||||
|
pause: () => calls.push('prefetch:pause'),
|
||||||
|
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
|
||||||
|
}),
|
||||||
|
getLastObservedTimePos: () => 12,
|
||||||
|
getVisibleOverlayVisible: () => true,
|
||||||
|
emitSecondarySubtitle: () => {},
|
||||||
|
initSubtitlePrefetch: async () => {},
|
||||||
|
refreshSubtitlePrefetchFromActiveTrack: async () => {
|
||||||
|
calls.push('refresh-active-track');
|
||||||
|
activeParsedSubtitleCues = [{ startTime: 10, endTime: 20, text: '起動字幕' }];
|
||||||
|
},
|
||||||
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'request:sub-text',
|
||||||
|
'refresh-active-track',
|
||||||
|
'request:time-pos',
|
||||||
|
'set:起動字幕',
|
||||||
|
'prefetch:pause',
|
||||||
|
'emit:起動字幕',
|
||||||
|
'change:起動字幕',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before tokenization', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let currentSubText = '';
|
||||||
|
const mediaPath = '/media/video.mkv';
|
||||||
|
|
||||||
|
const runtime = createAutoplaySubtitlePrimingRuntime({
|
||||||
|
getCurrentMediaPath: () => mediaPath,
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentVideoPath: mediaPath,
|
||||||
|
requestProperty: async (name) => {
|
||||||
|
calls.push(`request:${name}`);
|
||||||
|
if (name === 'sub-text') return '起動字幕';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setCurrentSubText: (text) => {
|
||||||
|
currentSubText = text;
|
||||||
|
calls.push(`set:${text}`);
|
||||||
|
},
|
||||||
|
getCurrentSubText: () => currentSubText,
|
||||||
|
getCurrentSubtitleData: () => null,
|
||||||
|
getActiveParsedSubtitleCues: () => [],
|
||||||
|
setActiveParsedSubtitleMediaPath: () => {},
|
||||||
|
subtitleProcessingController: {
|
||||||
|
consumeCachedSubtitle: () => null,
|
||||||
|
onSubtitleChange: (text) => calls.push(`change:${text}`),
|
||||||
|
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text ?? ''}`),
|
||||||
|
},
|
||||||
|
emitSubtitlePayload: (payload) => calls.push(`emit:${payload.text}`),
|
||||||
|
getSubtitlePrefetchService: () => ({
|
||||||
|
pause: () => calls.push('prefetch:pause'),
|
||||||
|
onSeek: (timePos) => calls.push(`prefetch:seek:${timePos}`),
|
||||||
|
}),
|
||||||
|
getLastObservedTimePos: () => 12,
|
||||||
|
getVisibleOverlayVisible: () => true,
|
||||||
|
emitSecondarySubtitle: () => {},
|
||||||
|
initSubtitlePrefetch: async () => {},
|
||||||
|
refreshSubtitlePrefetchFromActiveTrack: async () => {
|
||||||
|
calls.push('refresh-active-track');
|
||||||
|
},
|
||||||
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'request:sub-text',
|
||||||
|
'set:起動字幕',
|
||||||
|
'prefetch:pause',
|
||||||
|
'emit:起動字幕',
|
||||||
|
'change:起動字幕',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import type { SubtitleCue, SubtitleData } from '../../types';
|
||||||
|
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||||
|
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
||||||
|
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||||
|
|
||||||
|
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
|
||||||
|
const VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS = 100;
|
||||||
|
|
||||||
|
type AutoplaySubtitlePrimingMpvClient = {
|
||||||
|
connected?: boolean;
|
||||||
|
requestProperty: (name: string) => Promise<unknown>;
|
||||||
|
currentVideoPath?: string;
|
||||||
|
currentTimePos?: number;
|
||||||
|
currentSecondarySubText?: string;
|
||||||
|
setCurrentSecondarySubText?: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutoplaySubtitlePrimingPrefetchService = {
|
||||||
|
pause: () => void;
|
||||||
|
onSeek: (timePos: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AutoplaySubtitlePrimingRuntimeDeps {
|
||||||
|
getCurrentMediaPath: () => string | null | undefined;
|
||||||
|
getMpvClient: () => AutoplaySubtitlePrimingMpvClient | null;
|
||||||
|
setCurrentSubText: (text: string) => void;
|
||||||
|
getCurrentSubText: () => string;
|
||||||
|
getCurrentSubtitleData: () => SubtitleData | null;
|
||||||
|
getActiveParsedSubtitleCues: () => SubtitleCue[];
|
||||||
|
setActiveParsedSubtitleMediaPath: (mediaPath: string | null) => void;
|
||||||
|
subtitleProcessingController: {
|
||||||
|
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||||
|
onSubtitleChange: (text: string) => void;
|
||||||
|
refreshCurrentSubtitle: (text: string) => void;
|
||||||
|
};
|
||||||
|
emitSubtitlePayload: (payload: SubtitleData) => void;
|
||||||
|
getSubtitlePrefetchService: () => AutoplaySubtitlePrimingPrefetchService | null;
|
||||||
|
getLastObservedTimePos: () => number;
|
||||||
|
getVisibleOverlayVisible: () => boolean;
|
||||||
|
emitSecondarySubtitle: (text: string) => void;
|
||||||
|
initSubtitlePrefetch: (
|
||||||
|
sourcePath: string,
|
||||||
|
currentTimePos: number,
|
||||||
|
sourceKey?: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
|
||||||
|
logDebug: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMpvCurrentSecondarySubText(
|
||||||
|
client: Pick<
|
||||||
|
AutoplaySubtitlePrimingMpvClient,
|
||||||
|
'currentSecondarySubText' | 'setCurrentSecondarySubText'
|
||||||
|
>,
|
||||||
|
text: string,
|
||||||
|
): void {
|
||||||
|
if (typeof client.setCurrentSecondarySubText === 'function') {
|
||||||
|
client.setCurrentSecondarySubText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.currentSecondarySubText = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimingRuntimeDeps) {
|
||||||
|
const { subtitleProcessingController, emitSubtitlePayload } = deps;
|
||||||
|
|
||||||
|
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let autoplaySubtitlePrimedMediaPath: string | null = null;
|
||||||
|
let visibleOverlaySubtitleRefreshAfterFirstPaintTimer: ReturnType<typeof setTimeout> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
function getCurrentAutoplayMediaPath(): string | null {
|
||||||
|
return (
|
||||||
|
deps.getCurrentMediaPath()?.trim() || deps.getMpvClient()?.currentVideoPath?.trim() || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentAutoplayMediaPath(mediaPath: string): boolean {
|
||||||
|
return getCurrentAutoplayMediaPath() === mediaPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean {
|
||||||
|
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
autoplaySubtitlePrimedMediaPath = mediaPath;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAutoplaySubtitlePrime(): void {
|
||||||
|
autoplaySubtitlePrimedMediaPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
|
||||||
|
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.setCurrentSubText(text);
|
||||||
|
deps.getSubtitlePrefetchService()?.pause();
|
||||||
|
const cachedPayload = subtitleProcessingController.consumeCachedSubtitle(text);
|
||||||
|
if (cachedPayload) {
|
||||||
|
subtitleProcessingController.onSubtitleChange(text);
|
||||||
|
emitSubtitlePayload(cachedPayload);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitSubtitlePayload({ text, tokens: null });
|
||||||
|
subtitleProcessingController.onSubtitleChange(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
|
||||||
|
const client = deps.getMpvClient();
|
||||||
|
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subTextRaw = await client.requestProperty('sub-text').catch((error) => {
|
||||||
|
deps.logDebug(
|
||||||
|
`[autoplay-subtitle-prime] failed to read sub-text: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||||
|
if (emitAutoplayPrimedSubtitle(mediaPath, text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.trim() && isCurrentAutoplayMediaPath(mediaPath)) {
|
||||||
|
await deps.refreshSubtitlePrefetchFromActiveTrack().catch((error) => {
|
||||||
|
deps.logDebug(
|
||||||
|
`[autoplay-subtitle-prime] active subtitle refresh failed after empty sub-text: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await primeAutoplaySubtitleFromParsedCues(mediaPath, deps.getActiveParsedSubtitleCues());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
|
||||||
|
await primeVisibleOverlaySubtitleFromMpv({
|
||||||
|
getMpvClient: () => deps.getMpvClient(),
|
||||||
|
setCurrentSubText: (text) => {
|
||||||
|
deps.setCurrentSubText(text);
|
||||||
|
},
|
||||||
|
getCurrentSubtitleData: () => deps.getCurrentSubtitleData(),
|
||||||
|
consumeCachedSubtitle: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
|
||||||
|
onSubtitleChange: (text) => {
|
||||||
|
deps.getSubtitlePrefetchService()?.pause();
|
||||||
|
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||||
|
subtitleProcessingController.onSubtitleChange(text);
|
||||||
|
},
|
||||||
|
refreshCurrentSubtitle: (text) => {
|
||||||
|
deps.getSubtitlePrefetchService()?.pause();
|
||||||
|
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||||
|
},
|
||||||
|
deferUncachedRefresh: true,
|
||||||
|
emitSubtitle: (payload) => emitSubtitlePayload(payload),
|
||||||
|
setCurrentSecondarySubText: (text) => {
|
||||||
|
const client = deps.getMpvClient();
|
||||||
|
if (client) {
|
||||||
|
setMpvCurrentSecondarySubText(client, text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emitSecondarySubtitle: (text) => {
|
||||||
|
deps.emitSecondarySubtitle(text);
|
||||||
|
},
|
||||||
|
logDebug: (message) => {
|
||||||
|
deps.logDebug(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
||||||
|
if (!visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(visibleOverlaySubtitleRefreshAfterFirstPaintTimer);
|
||||||
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint(): void {
|
||||||
|
if (visibleOverlaySubtitleRefreshAfterFirstPaintTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!deps.getVisibleOverlayVisible() || !deps.getCurrentSubText().trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = setTimeout(() => {
|
||||||
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer = null;
|
||||||
|
if (!deps.getVisibleOverlayVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = deps.getCurrentSubText();
|
||||||
|
if (!text.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.getSubtitlePrefetchService()?.pause();
|
||||||
|
deps.getSubtitlePrefetchService()?.onSeek(deps.getLastObservedTimePos());
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(text);
|
||||||
|
}, VISIBLE_OVERLAY_SUBTITLE_REFRESH_AFTER_FIRST_PAINT_DELAY_MS);
|
||||||
|
visibleOverlaySubtitleRefreshAfterFirstPaintTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function primeAutoplaySubtitleFromParsedCues(
|
||||||
|
mediaPath: string,
|
||||||
|
cues: SubtitleCue[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
cues.length === 0 ||
|
||||||
|
autoplaySubtitlePrimedMediaPath === mediaPath ||
|
||||||
|
!isCurrentAutoplayMediaPath(mediaPath)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = deps.getMpvClient();
|
||||||
|
const timePosRaw = await client?.requestProperty('time-pos').catch(() => null);
|
||||||
|
const currentTimeSeconds = Number(
|
||||||
|
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
||||||
|
);
|
||||||
|
const cue = selectAutoplayStartupCue(
|
||||||
|
cues,
|
||||||
|
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
||||||
|
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
|
||||||
|
);
|
||||||
|
if (!cue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||||
|
if (subtitlePrefetchRefreshTimer) {
|
||||||
|
clearTimeout(subtitlePrefetchRefreshTimer);
|
||||||
|
subtitlePrefetchRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSubtitleSidebarFromSource(
|
||||||
|
sourcePath: string,
|
||||||
|
mediaPath?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const normalizedSourcePath = resolveSubtitleSourcePath(sourcePath.trim());
|
||||||
|
if (!normalizedSourcePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
||||||
|
await deps.initSubtitlePrefetch(
|
||||||
|
normalizedSourcePath,
|
||||||
|
deps.getLastObservedTimePos(),
|
||||||
|
normalizedSourcePath,
|
||||||
|
);
|
||||||
|
deps.setActiveParsedSubtitleMediaPath(nextMediaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
|
||||||
|
clearScheduledSubtitlePrefetchRefresh();
|
||||||
|
subtitlePrefetchRefreshTimer = setTimeout(() => {
|
||||||
|
subtitlePrefetchRefreshTimer = null;
|
||||||
|
void deps.refreshSubtitlePrefetchFromActiveTrack().catch((error) => {
|
||||||
|
deps.logDebug(
|
||||||
|
`[autoplay-subtitle-prime] subtitle prefetch refresh failed: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCurrentAutoplayMediaPath,
|
||||||
|
resetAutoplaySubtitlePrime,
|
||||||
|
primeCurrentSubtitleForAutoplay,
|
||||||
|
primeCurrentSubtitleForVisibleOverlay,
|
||||||
|
cancelVisibleOverlaySubtitleRefreshAfterFirstPaint,
|
||||||
|
scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint,
|
||||||
|
primeAutoplaySubtitleFromParsedCues,
|
||||||
|
clearScheduledSubtitlePrefetchRefresh,
|
||||||
|
refreshSubtitleSidebarFromSource,
|
||||||
|
scheduleSubtitlePrefetchRefresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -66,6 +66,9 @@ test('build cli command context deps maps handlers and values', () => {
|
|||||||
runUpdateCommand: async () => {
|
runUpdateCommand: async () => {
|
||||||
calls.push('run-update');
|
calls.push('run-update');
|
||||||
},
|
},
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
|
||||||
|
calls.push('run-ensure-linux-runtime-plugin-assets');
|
||||||
|
},
|
||||||
runYoutubePlaybackFlow: async () => {
|
runYoutubePlaybackFlow: async () => {
|
||||||
calls.push('run-youtube-playback');
|
calls.push('run-youtube-playback');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandContextFactoryDeps['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
@@ -100,6 +101,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
|
|||||||
runStatsCommand: deps.runStatsCommand,
|
runStatsCommand: deps.runStatsCommand,
|
||||||
runJellyfinCommand: deps.runJellyfinCommand,
|
runJellyfinCommand: deps.runJellyfinCommand,
|
||||||
runUpdateCommand: deps.runUpdateCommand,
|
runUpdateCommand: deps.runUpdateCommand,
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ test('cli command context factory composes main deps and context handlers', () =
|
|||||||
runStatsCommand: async () => {},
|
runStatsCommand: async () => {},
|
||||||
runJellyfinCommand: async () => {},
|
runJellyfinCommand: async () => {},
|
||||||
runUpdateCommand: async () => {},
|
runUpdateCommand: async () => {},
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||||
runYoutubePlaybackFlow: async () => {},
|
runYoutubePlaybackFlow: async () => {},
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
|
|||||||
runUpdateCommand: async () => {
|
runUpdateCommand: async () => {
|
||||||
calls.push('run-update');
|
calls.push('run-update');
|
||||||
},
|
},
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: async () => {
|
||||||
|
calls.push('run-ensure-linux-runtime-plugin-assets');
|
||||||
|
},
|
||||||
runYoutubePlaybackFlow: async () => {
|
runYoutubePlaybackFlow: async () => {
|
||||||
calls.push('run-youtube-playback');
|
calls.push('run-youtube-playback');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
runUpdateCommand: CliCommandContextFactoryDeps['runUpdateCommand'];
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: (
|
||||||
|
args: CliArgs,
|
||||||
|
source: CliCommandSource,
|
||||||
|
) => Promise<void>;
|
||||||
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
|
||||||
|
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
@@ -133,6 +137,8 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
|
|||||||
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
|
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
|
||||||
runUpdateCommand: (args: CliArgs, source: CliCommandSource) =>
|
runUpdateCommand: (args: CliArgs, source: CliCommandSource) =>
|
||||||
deps.runUpdateCommand(args, source),
|
deps.runUpdateCommand(args, source),
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: (args: CliArgs, source: CliCommandSource) =>
|
||||||
|
deps.runEnsureLinuxRuntimePluginAssetsCommand(args, source),
|
||||||
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
|
||||||
openYomitanSettings: () => deps.openYomitanSettings(),
|
openYomitanSettings: () => deps.openYomitanSettings(),
|
||||||
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
openConfigSettingsWindow: () => deps.openConfigSettingsWindow(),
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ function createDeps() {
|
|||||||
runStatsCommand: async () => {},
|
runStatsCommand: async () => {},
|
||||||
runJellyfinCommand: async () => {},
|
runJellyfinCommand: async () => {},
|
||||||
runUpdateCommand: async () => {},
|
runUpdateCommand: async () => {},
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||||
runYoutubePlaybackFlow: async () => {},
|
runYoutubePlaybackFlow: async () => {},
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export type CliCommandContextFactoryDeps = {
|
|||||||
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
|
runUpdateCommand: CliCommandRuntimeServiceContext['runUpdateCommand'];
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: CliCommandRuntimeServiceContext['runEnsureLinuxRuntimePluginAssetsCommand'];
|
||||||
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
runYoutubePlaybackFlow: CliCommandRuntimeServiceContext['runYoutubePlaybackFlow'];
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
openConfigSettingsWindow: () => void;
|
openConfigSettingsWindow: () => void;
|
||||||
@@ -127,6 +128,7 @@ export function createCliCommandContext(
|
|||||||
runStatsCommand: deps.runStatsCommand,
|
runStatsCommand: deps.runStatsCommand,
|
||||||
runJellyfinCommand: deps.runJellyfinCommand,
|
runJellyfinCommand: deps.runJellyfinCommand,
|
||||||
runUpdateCommand: deps.runUpdateCommand,
|
runUpdateCommand: deps.runUpdateCommand,
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: deps.runEnsureLinuxRuntimePluginAssetsCommand,
|
||||||
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
|
||||||
openYomitanSettings: deps.openYomitanSettings,
|
openYomitanSettings: deps.openYomitanSettings,
|
||||||
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
openConfigSettingsWindow: deps.openConfigSettingsWindow,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
|
|||||||
runJellyfinCommand: async () => {},
|
runJellyfinCommand: async () => {},
|
||||||
runStatsCommand: async () => {},
|
runStatsCommand: async () => {},
|
||||||
runUpdateCommand: async () => {},
|
runUpdateCommand: async () => {},
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCommand: async () => {},
|
||||||
runYoutubePlaybackFlow: async () => {},
|
runYoutubePlaybackFlow: async () => {},
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
openConfigSettingsWindow: () => {},
|
openConfigSettingsWindow: () => {},
|
||||||
|
|||||||
@@ -180,6 +180,21 @@ export function detectInstalledFirstRunPluginCandidates(options: {
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function detectWindowsMpvPluginRemovalCandidates(options: {
|
||||||
|
homeDir: string;
|
||||||
|
appDataDir: string;
|
||||||
|
mpvExecutablePath: string;
|
||||||
|
existsSync?: (candidate: string) => boolean;
|
||||||
|
}): InstalledFirstRunPluginCandidate[] {
|
||||||
|
return detectInstalledFirstRunPluginCandidates({
|
||||||
|
platform: 'win32',
|
||||||
|
homeDir: options.homeDir,
|
||||||
|
appDataDir: options.appDataDir,
|
||||||
|
mpvExecutablePath: options.mpvExecutablePath,
|
||||||
|
existsSync: options.existsSync,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function parseInstalledPluginVersion(content: string): string | null {
|
function parseInstalledPluginVersion(content: string): string | null {
|
||||||
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
|
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
|
||||||
return match?.[1] ?? null;
|
return match?.[1] ?? null;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
buildFfmpegSubtitleExtractionArgs,
|
||||||
|
extractInternalSubtitleTrackToTempFile,
|
||||||
|
parseTrackId,
|
||||||
|
} from './internal-subtitle-extraction';
|
||||||
|
|
||||||
|
test('buildFfmpegSubtitleExtractionArgs rejects output paths without an extension', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => buildFfmpegSubtitleExtractionArgs('/tmp/video.mkv', 2, '/tmp/subtitle-output'),
|
||||||
|
/outputPath.*file extension/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseTrackId rejects negative track ids', () => {
|
||||||
|
assert.equal(parseTrackId(-1), null);
|
||||||
|
assert.equal(parseTrackId(' -2 '), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractInternalSubtitleTrackToTempFile times out stalled ffmpeg process', async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-ffmpeg-timeout-'));
|
||||||
|
const videoPath = path.join(root, 'video.mkv');
|
||||||
|
fs.writeFileSync(videoPath, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
extractInternalSubtitleTrackToTempFile(
|
||||||
|
process.execPath,
|
||||||
|
videoPath,
|
||||||
|
{ 'ff-index': 0, codec: 'ass' },
|
||||||
|
{
|
||||||
|
extractionTimeoutMs: 20,
|
||||||
|
spawnArgsOverride: ['-e', 'setTimeout(() => {}, 1000);'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/ffmpeg extraction timed out/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||||
|
import { codecToExtension } from '../../subsync/utils';
|
||||||
|
|
||||||
|
export async function loadSubtitleSourceText(source: string): Promise<string> {
|
||||||
|
if (/^https?:\/\//i.test(source)) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 4000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(source, { signal: controller.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download subtitle source (${response.status})`);
|
||||||
|
}
|
||||||
|
return await response.text();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = resolveSubtitleSourcePath(source);
|
||||||
|
return fs.promises.readFile(filePath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MpvSubtitleTrackLike = {
|
||||||
|
type?: unknown;
|
||||||
|
id?: unknown;
|
||||||
|
selected?: unknown;
|
||||||
|
external?: unknown;
|
||||||
|
codec?: unknown;
|
||||||
|
'ff-index'?: unknown;
|
||||||
|
'external-filename'?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
export function parseTrackId(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFfmpegSubtitleExtractionArgs(
|
||||||
|
videoPath: string,
|
||||||
|
ffIndex: number,
|
||||||
|
outputPath: string,
|
||||||
|
): string[] {
|
||||||
|
const outputFormat = path.extname(outputPath).slice(1);
|
||||||
|
if (!outputFormat) {
|
||||||
|
throw new Error(`outputPath must include a file extension for ffmpeg format: ${outputPath}`);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'-hide_banner',
|
||||||
|
'-nostdin',
|
||||||
|
'-y',
|
||||||
|
'-loglevel',
|
||||||
|
'error',
|
||||||
|
'-an',
|
||||||
|
'-vn',
|
||||||
|
'-i',
|
||||||
|
videoPath,
|
||||||
|
'-map',
|
||||||
|
`0:${ffIndex}`,
|
||||||
|
'-f',
|
||||||
|
outputFormat,
|
||||||
|
outputPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractInternalSubtitleTrackToTempFile(
|
||||||
|
ffmpegPath: string,
|
||||||
|
videoPath: string,
|
||||||
|
track: MpvSubtitleTrackLike,
|
||||||
|
options: { extractionTimeoutMs?: number; spawnArgsOverride?: string[] } = {},
|
||||||
|
): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
|
||||||
|
const ffIndex = parseTrackId(track['ff-index']);
|
||||||
|
const codec = typeof track.codec === 'string' ? track.codec : null;
|
||||||
|
const extension = codecToExtension(codec ?? undefined);
|
||||||
|
if (ffIndex === null || extension === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'subminer-sidebar-'));
|
||||||
|
const outputPath = path.join(tempDir, `track_${ffIndex}.${extension}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const child = spawn(
|
||||||
|
ffmpegPath,
|
||||||
|
options.spawnArgsOverride ??
|
||||||
|
buildFfmpegSubtitleExtractionArgs(videoPath, ffIndex, outputPath),
|
||||||
|
);
|
||||||
|
const extractionTimeoutMs = options.extractionTimeoutMs ?? DEFAULT_EXTRACTION_TIMEOUT_MS;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(new Error(`ffmpeg extraction timed out after ${extractionTimeoutMs}ms`));
|
||||||
|
}, extractionTimeoutMs);
|
||||||
|
const settle = (callback: () => void): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on('error', (error) => {
|
||||||
|
settle(() => reject(error));
|
||||||
|
});
|
||||||
|
child.on('close', (code) => {
|
||||||
|
settle(() => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: outputPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -341,6 +341,21 @@ test('tick only writes interaction state on change', () => {
|
|||||||
assert.deepEqual(calls, [true]);
|
assert.deepEqual(calls, [true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tick reapplies an unchanged inactive state when the window passthrough state is dirty', () => {
|
||||||
|
const calls: boolean[] = [];
|
||||||
|
const { deps, state } = makeDeps({
|
||||||
|
getCursorScreenPoint: () => ({ x: 200, y: 200 }),
|
||||||
|
isInteractionStateApplied: () => false,
|
||||||
|
setInteractionActive: (active) => {
|
||||||
|
calls.push(active);
|
||||||
|
state.active = active;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
tickLinuxOverlayPointerInteraction(deps);
|
||||||
|
assert.deepEqual(calls, [false]);
|
||||||
|
});
|
||||||
|
|
||||||
test('tick does not flip state when suspended (returns null)', () => {
|
test('tick does not flip state when suspended (returns null)', () => {
|
||||||
const calls: boolean[] = [];
|
const calls: boolean[] = [];
|
||||||
const { deps } = makeDeps({
|
const { deps } = makeDeps({
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export type LinuxOverlayPointerInteractionDeps = {
|
|||||||
shouldSuppressInteraction?: () => boolean;
|
shouldSuppressInteraction?: () => boolean;
|
||||||
shouldUseInputShape?: () => boolean;
|
shouldUseInputShape?: () => boolean;
|
||||||
getInteractionActive: () => boolean;
|
getInteractionActive: () => boolean;
|
||||||
|
isInteractionStateApplied?: () => boolean;
|
||||||
setInteractionActive: (active: boolean) => void;
|
setInteractionActive: (active: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -273,7 +274,9 @@ export function tickLinuxOverlayPointerInteraction(deps: LinuxOverlayPointerInte
|
|||||||
if (deps.shouldUseInputShape?.()) return;
|
if (deps.shouldUseInputShape?.()) return;
|
||||||
const desired = resolveDesiredOverlayInteractive(deps);
|
const desired = resolveDesiredOverlayInteractive(deps);
|
||||||
if (desired === null) return;
|
if (desired === null) return;
|
||||||
if (deps.getInteractionActive() === desired) return;
|
if (deps.getInteractionActive() === desired && deps.isInteractionStateApplied?.() !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
deps.setInteractionActive(desired);
|
deps.setInteractionActive(desired);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { CliArgs } from '../../cli/args';
|
||||||
|
import { runEnsureLinuxRuntimePluginAssetsCliCommand } from './linux-runtime-plugin-assets-cli-command';
|
||||||
|
|
||||||
|
test('runEnsureLinuxRuntimePluginAssetsCliCommand writes success response for install', async () => {
|
||||||
|
const writes: Array<{ path: string; payload: unknown }> = [];
|
||||||
|
|
||||||
|
await runEnsureLinuxRuntimePluginAssetsCliCommand(
|
||||||
|
{
|
||||||
|
ensureLinuxRuntimePluginAssets: true,
|
||||||
|
ensureLinuxRuntimePluginAssetsResponsePath: '/tmp/subminer-plugin-response.json',
|
||||||
|
} as CliArgs,
|
||||||
|
{
|
||||||
|
ensureLinuxRuntimePluginAssets: async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: '/home/tester/.local/share/SubMiner/plugin/subminer/main.lua',
|
||||||
|
}),
|
||||||
|
writeResponse: (responsePath, payload) => {
|
||||||
|
writes.push({ path: responsePath, payload });
|
||||||
|
},
|
||||||
|
logWarn: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(writes, [
|
||||||
|
{
|
||||||
|
path: '/tmp/subminer-plugin-response.json',
|
||||||
|
payload: {
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: '/home/tester/.local/share/SubMiner/plugin/subminer/main.lua',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runEnsureLinuxRuntimePluginAssetsCliCommand writes failure response on error', async () => {
|
||||||
|
const writes: Array<{ path: string; payload: unknown }> = [];
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runEnsureLinuxRuntimePluginAssetsCliCommand(
|
||||||
|
{
|
||||||
|
ensureLinuxRuntimePluginAssets: true,
|
||||||
|
ensureLinuxRuntimePluginAssetsResponsePath: '/tmp/subminer-plugin-response.json',
|
||||||
|
} as CliArgs,
|
||||||
|
{
|
||||||
|
ensureLinuxRuntimePluginAssets: async () => {
|
||||||
|
throw new Error('copy failed');
|
||||||
|
},
|
||||||
|
writeResponse: (responsePath, payload) => {
|
||||||
|
writes.push({ path: responsePath, payload });
|
||||||
|
},
|
||||||
|
logWarn: () => {},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/copy failed/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(writes, [
|
||||||
|
{
|
||||||
|
path: '/tmp/subminer-plugin-response.json',
|
||||||
|
payload: {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'copy failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { CliArgs, CliCommandSource } from '../../cli/args';
|
||||||
|
import {
|
||||||
|
ensureLinuxRuntimePluginAssets,
|
||||||
|
type EnsureLinuxRuntimePluginAssetsResult,
|
||||||
|
} from './linux-runtime-plugin-assets';
|
||||||
|
|
||||||
|
export interface EnsureLinuxRuntimePluginAssetsResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: 'installed' | 'already-present' | 'failed';
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnsureLinuxRuntimePluginAssetsCliCommandDeps {
|
||||||
|
ensureLinuxRuntimePluginAssets: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
|
||||||
|
writeResponse: (responsePath: string, payload: EnsureLinuxRuntimePluginAssetsResponse) => void;
|
||||||
|
logWarn: (message: string, error?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeEnsureLinuxRuntimePluginAssetsCliCommandResponse(
|
||||||
|
responsePath: string,
|
||||||
|
payload: EnsureLinuxRuntimePluginAssetsResponse,
|
||||||
|
): void {
|
||||||
|
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||||
|
fs.writeFileSync(responsePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeResponseSafe(
|
||||||
|
responsePath: string | undefined,
|
||||||
|
payload: EnsureLinuxRuntimePluginAssetsResponse,
|
||||||
|
deps: Pick<EnsureLinuxRuntimePluginAssetsCliCommandDeps, 'writeResponse' | 'logWarn'>,
|
||||||
|
): void {
|
||||||
|
if (!responsePath) return;
|
||||||
|
try {
|
||||||
|
deps.writeResponse(responsePath, payload);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn(`Failed to write Linux runtime plugin asset response: ${responsePath}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDeps: EnsureLinuxRuntimePluginAssetsCliCommandDeps = {
|
||||||
|
ensureLinuxRuntimePluginAssets: () => ensureLinuxRuntimePluginAssets(),
|
||||||
|
writeResponse: (responsePath, payload) =>
|
||||||
|
writeEnsureLinuxRuntimePluginAssetsCliCommandResponse(responsePath, payload),
|
||||||
|
logWarn: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runEnsureLinuxRuntimePluginAssetsCliCommand(
|
||||||
|
args: Pick<
|
||||||
|
CliArgs,
|
||||||
|
'ensureLinuxRuntimePluginAssets' | 'ensureLinuxRuntimePluginAssetsResponsePath'
|
||||||
|
>,
|
||||||
|
deps: EnsureLinuxRuntimePluginAssetsCliCommandDeps = defaultDeps,
|
||||||
|
_source: CliCommandSource = 'initial',
|
||||||
|
): Promise<EnsureLinuxRuntimePluginAssetsResult> {
|
||||||
|
try {
|
||||||
|
const result = await deps.ensureLinuxRuntimePluginAssets();
|
||||||
|
writeResponseSafe(args.ensureLinuxRuntimePluginAssetsResponsePath, result, deps);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
writeResponseSafe(
|
||||||
|
args.ensureLinuxRuntimePluginAssetsResponsePath,
|
||||||
|
{ ok: false, status: 'failed', error: message },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
ensureLinuxRuntimePluginAssets,
|
||||||
|
resolveManagedLinuxRuntimePluginPaths,
|
||||||
|
} from './linux-runtime-plugin-assets';
|
||||||
|
|
||||||
|
async function withTempDir<T>(fn: (dir: string) => Promise<T> | T): Promise<T> {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-linux-plugin-assets-test-'));
|
||||||
|
try {
|
||||||
|
return await fn(dir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withProcessResourcesPath<T>(
|
||||||
|
resourcesPath: string,
|
||||||
|
fn: () => Promise<T> | T,
|
||||||
|
): Promise<T> {
|
||||||
|
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
|
||||||
|
const previousResourcesPath = processWithResources.resourcesPath;
|
||||||
|
processWithResources.resourcesPath = resourcesPath;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
if (previousResourcesPath === undefined) {
|
||||||
|
Reflect.deleteProperty(processWithResources, 'resourcesPath');
|
||||||
|
} else {
|
||||||
|
processWithResources.resourcesPath = previousResourcesPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('resolveManagedLinuxRuntimePluginPaths resolves XDG data target paths', () => {
|
||||||
|
const resolved = resolveManagedLinuxRuntimePluginPaths({
|
||||||
|
homeDir: '/home/tester',
|
||||||
|
xdgDataHome: '/tmp/xdg-data',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(resolved, {
|
||||||
|
dataDir: '/tmp/xdg-data/SubMiner',
|
||||||
|
rootDir: '/tmp/xdg-data/SubMiner/plugin',
|
||||||
|
pluginDir: '/tmp/xdg-data/SubMiner/plugin/subminer',
|
||||||
|
pluginEntrypointPath: '/tmp/xdg-data/SubMiner/plugin/subminer/main.lua',
|
||||||
|
pluginConfigPath: '/tmp/xdg-data/SubMiner/plugin/subminer.conf',
|
||||||
|
themePath: '/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveManagedLinuxRuntimePluginPaths treats blank XDG data homes as unset', () => {
|
||||||
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
try {
|
||||||
|
delete process.env.XDG_DATA_HOME;
|
||||||
|
const emptyOption = resolveManagedLinuxRuntimePluginPaths({
|
||||||
|
homeDir: '/home/tester',
|
||||||
|
xdgDataHome: '',
|
||||||
|
});
|
||||||
|
assert.equal(emptyOption.dataDir, path.join('/home/tester', '.local', 'share', 'SubMiner'));
|
||||||
|
|
||||||
|
process.env.XDG_DATA_HOME = ' ';
|
||||||
|
const blankEnv = resolveManagedLinuxRuntimePluginPaths({
|
||||||
|
homeDir: '/home/tester',
|
||||||
|
});
|
||||||
|
assert.equal(blankEnv.dataDir, path.join('/home/tester', '.local', 'share', 'SubMiner'));
|
||||||
|
} finally {
|
||||||
|
if (previousXdgDataHome === undefined) {
|
||||||
|
delete process.env.XDG_DATA_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and rofi theme when missing', async () => {
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
||||||
|
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
|
||||||
|
const targetRoot = path.join(tempDir, 'xdg-data', 'SubMiner', 'plugin');
|
||||||
|
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(sourceRoot, 'subminer', 'main.lua'), '-- plugin\n');
|
||||||
|
fs.writeFileSync(path.join(sourceRoot, 'subminer.conf'), 'configured=true\n');
|
||||||
|
fs.writeFileSync(themeSourcePath, '/* theme */\n');
|
||||||
|
|
||||||
|
const result = await ensureLinuxRuntimePluginAssets({
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: path.join(tempDir, 'home'),
|
||||||
|
xdgDataHome: path.join(tempDir, 'xdg-data'),
|
||||||
|
resolveBundledAssets: () => ({
|
||||||
|
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
||||||
|
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||||
|
themeSourcePath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: path.join(targetRoot, 'subminer', 'main.lua'),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(targetRoot, 'subminer', 'main.lua'), 'utf8'),
|
||||||
|
'-- plugin\n',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(targetRoot, 'subminer.conf'), 'utf8'),
|
||||||
|
'configured=true\n',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(tempDir, 'xdg-data', 'SubMiner', 'themes', 'subminer.rasi'),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
'/* theme */\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets already exist', async () => {
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
||||||
|
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
|
||||||
|
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
||||||
|
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||||
|
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(targetRoot, 'subminer'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(sourceRoot, 'subminer', 'main.lua'), '-- new plugin\n');
|
||||||
|
fs.writeFileSync(path.join(sourceRoot, 'subminer.conf'), 'new=true\n');
|
||||||
|
fs.writeFileSync(themeSourcePath, '/* theme */\n');
|
||||||
|
fs.writeFileSync(path.join(targetRoot, 'subminer', 'main.lua'), '-- existing plugin\n');
|
||||||
|
fs.writeFileSync(path.join(targetRoot, 'subminer.conf'), 'configured=true\n');
|
||||||
|
|
||||||
|
const result = await ensureLinuxRuntimePluginAssets({
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: path.join(tempDir, 'home'),
|
||||||
|
xdgDataHome,
|
||||||
|
resolveBundledAssets: () => ({
|
||||||
|
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
||||||
|
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||||
|
themeSourcePath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: path.join(targetRoot, 'subminer', 'main.lua'),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(targetRoot, 'subminer', 'main.lua'), 'utf8'),
|
||||||
|
'-- existing plugin\n',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(targetRoot, 'subminer.conf'), 'utf8'),
|
||||||
|
'configured=true\n',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'), 'utf8'),
|
||||||
|
'/* theme */\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAssets installs managed theme without resolving plugin sources when plugin assets already exist', async () => {
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
|
||||||
|
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
||||||
|
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||||
|
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(targetRoot, 'subminer'), { recursive: true });
|
||||||
|
fs.writeFileSync(themeSourcePath, '/* theme */\n');
|
||||||
|
fs.writeFileSync(path.join(targetRoot, 'subminer', 'main.lua'), '-- existing plugin\n');
|
||||||
|
fs.writeFileSync(path.join(targetRoot, 'subminer.conf'), 'configured=true\n');
|
||||||
|
|
||||||
|
const result = await ensureLinuxRuntimePluginAssets({
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: path.join(tempDir, 'home'),
|
||||||
|
xdgDataHome,
|
||||||
|
resolveBundledAssets: () => ({
|
||||||
|
themeSourcePath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: path.join(targetRoot, 'subminer', 'main.lua'),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'), 'utf8'),
|
||||||
|
'/* theme */\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAssets default resolver can recover a missing theme when plugin sources are unavailable', async () => {
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
||||||
|
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||||
|
fs.mkdirSync(path.join(targetRoot, 'subminer'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(targetRoot, 'subminer', 'main.lua'), '-- existing plugin\n');
|
||||||
|
fs.writeFileSync(path.join(targetRoot, 'subminer.conf'), 'configured=true\n');
|
||||||
|
|
||||||
|
const result = await withProcessResourcesPath(process.cwd(), () =>
|
||||||
|
ensureLinuxRuntimePluginAssets({
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: path.join(tempDir, 'home'),
|
||||||
|
xdgDataHome,
|
||||||
|
existsSync: (candidate) => {
|
||||||
|
if (
|
||||||
|
!candidate.startsWith(tempDir) &&
|
||||||
|
(candidate.endsWith(path.join('plugin', 'subminer')) ||
|
||||||
|
candidate.endsWith(path.join('plugin', 'subminer.conf')) ||
|
||||||
|
candidate.endsWith(path.join('plugin', 'subminer', 'main.lua')))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return fs.existsSync(candidate);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: path.join(targetRoot, 'subminer', 'main.lua'),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'), 'utf8'),
|
||||||
|
fs.readFileSync(path.join(process.cwd(), 'assets', 'themes', 'subminer.rasi'), 'utf8'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAssets installs managed plugin assets without resolving theme source when theme already exists', async () => {
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
||||||
|
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
||||||
|
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||||
|
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(xdgDataHome, 'SubMiner', 'themes'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(sourceRoot, 'subminer', 'main.lua'), '-- plugin\n');
|
||||||
|
fs.writeFileSync(path.join(sourceRoot, 'subminer.conf'), 'configured=true\n');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
|
||||||
|
'/* existing theme */\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ensureLinuxRuntimePluginAssets({
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: path.join(tempDir, 'home'),
|
||||||
|
xdgDataHome,
|
||||||
|
resolveBundledAssets: () => ({
|
||||||
|
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
||||||
|
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: path.join(targetRoot, 'subminer', 'main.lua'),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(targetRoot, 'subminer', 'main.lua'), 'utf8'),
|
||||||
|
'-- plugin\n',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(targetRoot, 'subminer.conf'), 'utf8'),
|
||||||
|
'configured=true\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAssets default resolver can recover missing plugin assets when theme source is unavailable', async () => {
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
||||||
|
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||||
|
fs.mkdirSync(path.join(xdgDataHome, 'SubMiner', 'themes'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
|
||||||
|
'/* existing theme */\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await withProcessResourcesPath(process.cwd(), () =>
|
||||||
|
ensureLinuxRuntimePluginAssets({
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: path.join(tempDir, 'home'),
|
||||||
|
xdgDataHome,
|
||||||
|
existsSync: (candidate) => {
|
||||||
|
if (
|
||||||
|
!candidate.startsWith(tempDir) &&
|
||||||
|
candidate.endsWith(path.join('assets', 'themes', 'subminer.rasi'))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return fs.existsSync(candidate);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: path.join(targetRoot, 'subminer', 'main.lua'),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(targetRoot, 'subminer', 'main.lua'), 'utf8'),
|
||||||
|
fs.readFileSync(path.join(process.cwd(), 'plugin', 'subminer', 'main.lua'), 'utf8'),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(targetRoot, 'subminer.conf'), 'utf8'),
|
||||||
|
fs.readFileSync(path.join(process.cwd(), 'plugin', 'subminer.conf'), 'utf8'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAssets returns already-present when managed assets already exist', async () => {
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
||||||
|
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||||
|
fs.mkdirSync(path.join(targetRoot, 'subminer'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(xdgDataHome, 'SubMiner', 'themes'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(targetRoot, 'subminer', 'main.lua'), '-- existing\n');
|
||||||
|
fs.writeFileSync(path.join(targetRoot, 'subminer.conf'), 'configured=true\n');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
|
||||||
|
'/* theme */\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ensureLinuxRuntimePluginAssets({
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: path.join(tempDir, 'home'),
|
||||||
|
xdgDataHome,
|
||||||
|
resolveBundledAssets: () => {
|
||||||
|
throw new Error('should not resolve bundled assets when already installed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: true,
|
||||||
|
status: 'already-present',
|
||||||
|
path: path.join(targetRoot, 'subminer', 'main.lua'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAssets fails when bundled assets cannot be resolved', async () => {
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const result = await ensureLinuxRuntimePluginAssets({
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: path.join(tempDir, 'home'),
|
||||||
|
xdgDataHome: path.join(tempDir, 'xdg-data'),
|
||||||
|
resolveBundledAssets: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.status, 'failed');
|
||||||
|
assert.match(result.error ?? '', /bundled.*plugin assets/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed install', async () => {
|
||||||
|
await withTempDir(async (tempDir) => {
|
||||||
|
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
||||||
|
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
|
||||||
|
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
||||||
|
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||||
|
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(sourceRoot, 'subminer', 'main.lua'), '-- plugin\n');
|
||||||
|
fs.writeFileSync(path.join(sourceRoot, 'subminer.conf'), 'configured=true\n');
|
||||||
|
fs.writeFileSync(themeSourcePath, '/* theme */\n');
|
||||||
|
|
||||||
|
const result = await ensureLinuxRuntimePluginAssets({
|
||||||
|
platform: 'linux',
|
||||||
|
homeDir: path.join(tempDir, 'home'),
|
||||||
|
xdgDataHome,
|
||||||
|
resolveBundledAssets: () => ({
|
||||||
|
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
||||||
|
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||||
|
themeSourcePath,
|
||||||
|
}),
|
||||||
|
copyFile: async () => {
|
||||||
|
throw new Error('copy failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.status, 'failed');
|
||||||
|
assert.equal(fs.existsSync(path.join(targetRoot, 'subminer', 'main.lua')), false);
|
||||||
|
assert.equal(fs.existsSync(path.join(targetRoot, 'subminer.conf')), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { resolvePackagedFirstRunPluginAssets } from './first-run-setup-plugin';
|
||||||
|
|
||||||
|
export interface ManagedLinuxRuntimePluginPaths {
|
||||||
|
dataDir: string;
|
||||||
|
rootDir: string;
|
||||||
|
pluginDir: string;
|
||||||
|
pluginEntrypointPath: string;
|
||||||
|
pluginConfigPath: string;
|
||||||
|
themePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnsureLinuxRuntimePluginAssetsResult {
|
||||||
|
ok: boolean;
|
||||||
|
status: 'installed' | 'already-present' | 'failed';
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimePluginAssetSources {
|
||||||
|
pluginDirSource?: string;
|
||||||
|
pluginConfigSource?: string;
|
||||||
|
themeSourcePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimePluginDirentLike {
|
||||||
|
name: string;
|
||||||
|
isDirectory(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnsureLinuxRuntimePluginAssetsOptions {
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
homeDir?: string;
|
||||||
|
xdgDataHome?: string;
|
||||||
|
pathModule?: typeof path;
|
||||||
|
existsSync?: (candidate: string) => boolean;
|
||||||
|
resolveBundledAssets?: () => RuntimePluginAssetSources | null;
|
||||||
|
mkdir?: (targetPath: string, options: { recursive: true }) => Promise<void>;
|
||||||
|
readdir?: (
|
||||||
|
targetPath: string,
|
||||||
|
options: { withFileTypes: true },
|
||||||
|
) => Promise<RuntimePluginDirentLike[]>;
|
||||||
|
copyFile?: (sourcePath: string, targetPath: string) => Promise<void>;
|
||||||
|
rename?: (fromPath: string, toPath: string) => Promise<void>;
|
||||||
|
rm?: (targetPath: string, options: { recursive?: boolean; force?: boolean }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveManagedLinuxRuntimePluginPaths(options: {
|
||||||
|
homeDir?: string;
|
||||||
|
xdgDataHome?: string;
|
||||||
|
pathModule?: typeof path;
|
||||||
|
}): ManagedLinuxRuntimePluginPaths {
|
||||||
|
const pathModule = options.pathModule ?? path;
|
||||||
|
const homeDir = options.homeDir ?? os.homedir();
|
||||||
|
const explicitXdgDataHome = options.xdgDataHome?.trim();
|
||||||
|
const envXdgDataHome = process.env.XDG_DATA_HOME?.trim();
|
||||||
|
const xdgDataHome =
|
||||||
|
explicitXdgDataHome || envXdgDataHome || pathModule.join(homeDir, '.local', 'share');
|
||||||
|
const dataDir = pathModule.join(xdgDataHome, 'SubMiner');
|
||||||
|
const rootDir = pathModule.join(dataDir, 'plugin');
|
||||||
|
const pluginDir = pathModule.join(rootDir, 'subminer');
|
||||||
|
return {
|
||||||
|
dataDir,
|
||||||
|
rootDir,
|
||||||
|
pluginDir,
|
||||||
|
pluginEntrypointPath: pathModule.join(pluginDir, 'main.lua'),
|
||||||
|
pluginConfigPath: pathModule.join(rootDir, 'subminer.conf'),
|
||||||
|
themePath: pathModule.join(dataDir, 'themes', 'subminer.rasi'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyDirectoryRecursive(
|
||||||
|
sourceDir: string,
|
||||||
|
targetDir: string,
|
||||||
|
options: Required<
|
||||||
|
Pick<EnsureLinuxRuntimePluginAssetsOptions, 'mkdir' | 'readdir' | 'copyFile' | 'pathModule'>
|
||||||
|
>,
|
||||||
|
): Promise<void> {
|
||||||
|
await options.mkdir(targetDir, { recursive: true });
|
||||||
|
const entries = await options.readdir(sourceDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const sourcePath = options.pathModule.join(sourceDir, entry.name);
|
||||||
|
const targetPath = options.pathModule.join(targetDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await copyDirectoryRecursive(sourcePath, targetPath, options);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await options.copyFile(sourcePath, targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBundledThemePath(options: {
|
||||||
|
dirname: string;
|
||||||
|
appPath: string;
|
||||||
|
resourcesPath: string;
|
||||||
|
existsSync: (candidate: string) => boolean;
|
||||||
|
}): string | null {
|
||||||
|
const roots = [
|
||||||
|
path.join(options.resourcesPath, 'assets'),
|
||||||
|
path.join(options.resourcesPath, 'app.asar', 'assets'),
|
||||||
|
path.join(options.appPath, 'assets'),
|
||||||
|
path.join(options.dirname, '..', 'assets'),
|
||||||
|
path.join(options.dirname, '..', '..', 'assets'),
|
||||||
|
path.join(options.dirname, '..', '..', '..', 'assets'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
const candidate = path.join(root, 'themes', 'subminer.rasi');
|
||||||
|
if (options.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBundledAssetsDefault(
|
||||||
|
existsSync: (candidate: string) => boolean,
|
||||||
|
): RuntimePluginAssetSources {
|
||||||
|
const resourcesPath = process.resourcesPath ?? path.dirname(process.execPath);
|
||||||
|
const pluginAssets = resolvePackagedFirstRunPluginAssets({
|
||||||
|
dirname: __dirname,
|
||||||
|
appPath: process.execPath,
|
||||||
|
resourcesPath,
|
||||||
|
existsSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
const themeSourcePath = resolveBundledThemePath({
|
||||||
|
dirname: __dirname,
|
||||||
|
appPath: process.execPath,
|
||||||
|
resourcesPath,
|
||||||
|
existsSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(pluginAssets ?? {}),
|
||||||
|
...(themeSourcePath ? { themeSourcePath } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureLinuxRuntimePluginAssets(
|
||||||
|
options: EnsureLinuxRuntimePluginAssetsOptions = {},
|
||||||
|
): Promise<EnsureLinuxRuntimePluginAssetsResult> {
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
if (platform !== 'linux') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'Linux runtime plugin asset install is only supported on Linux.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathModule = options.pathModule ?? path;
|
||||||
|
const existsSync = options.existsSync ?? fs.existsSync;
|
||||||
|
const mkdir =
|
||||||
|
options.mkdir ??
|
||||||
|
(async (targetPath, mkdirOptions) => {
|
||||||
|
await fs.promises.mkdir(targetPath, mkdirOptions);
|
||||||
|
});
|
||||||
|
const readdir =
|
||||||
|
options.readdir ??
|
||||||
|
((targetPath, readdirOptions) =>
|
||||||
|
fs.promises.readdir(targetPath, readdirOptions) as Promise<RuntimePluginDirentLike[]>);
|
||||||
|
const copyFile =
|
||||||
|
options.copyFile ?? ((sourcePath, targetPath) => fs.promises.copyFile(sourcePath, targetPath));
|
||||||
|
const rename = options.rename ?? ((fromPath, toPath) => fs.promises.rename(fromPath, toPath));
|
||||||
|
const rm = options.rm ?? ((targetPath, rmOptions) => fs.promises.rm(targetPath, rmOptions));
|
||||||
|
|
||||||
|
const managedPaths = resolveManagedLinuxRuntimePluginPaths({
|
||||||
|
homeDir: options.homeDir,
|
||||||
|
xdgDataHome: options.xdgDataHome,
|
||||||
|
pathModule,
|
||||||
|
});
|
||||||
|
const pluginAssetsExist =
|
||||||
|
existsSync(managedPaths.pluginEntrypointPath) && existsSync(managedPaths.pluginConfigPath);
|
||||||
|
const themeExists = existsSync(managedPaths.themePath);
|
||||||
|
if (pluginAssetsExist && themeExists) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 'already-present',
|
||||||
|
path: managedPaths.pluginEntrypointPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundledAssets =
|
||||||
|
(options.resolveBundledAssets
|
||||||
|
? options.resolveBundledAssets()
|
||||||
|
: resolveBundledAssetsDefault(existsSync)) ?? {};
|
||||||
|
|
||||||
|
const shouldInstallPluginAssets = !pluginAssetsExist;
|
||||||
|
const shouldInstallTheme = !themeExists;
|
||||||
|
if (
|
||||||
|
shouldInstallPluginAssets &&
|
||||||
|
(!bundledAssets.pluginDirSource || !bundledAssets.pluginConfigSource)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'Bundled Linux runtime plugin assets were not found.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (shouldInstallTheme && !bundledAssets.themeSourcePath) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'Bundled Linux runtime theme asset was not found.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagingSuffix = `${process.pid}-${Date.now()}`;
|
||||||
|
const stagedPluginDir = pathModule.join(managedPaths.rootDir, `.subminer-stage-${stagingSuffix}`);
|
||||||
|
const stagedPluginConfigPath = pathModule.join(
|
||||||
|
managedPaths.rootDir,
|
||||||
|
`.subminer.conf-stage-${stagingSuffix}`,
|
||||||
|
);
|
||||||
|
const stagedThemePath = pathModule.join(
|
||||||
|
pathModule.dirname(managedPaths.themePath),
|
||||||
|
`.subminer.rasi-stage-${stagingSuffix}`,
|
||||||
|
);
|
||||||
|
let pluginDirInstalled = false;
|
||||||
|
let pluginConfigInstalled = false;
|
||||||
|
let themeInstalled = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (shouldInstallPluginAssets) {
|
||||||
|
const pluginDirSource = bundledAssets.pluginDirSource;
|
||||||
|
const pluginConfigSource = bundledAssets.pluginConfigSource;
|
||||||
|
if (!pluginDirSource || !pluginConfigSource) {
|
||||||
|
throw new Error('Bundled Linux runtime plugin assets were not found.');
|
||||||
|
}
|
||||||
|
await mkdir(managedPaths.rootDir, { recursive: true });
|
||||||
|
await copyDirectoryRecursive(pluginDirSource, stagedPluginDir, {
|
||||||
|
mkdir,
|
||||||
|
readdir,
|
||||||
|
copyFile,
|
||||||
|
pathModule,
|
||||||
|
});
|
||||||
|
await copyFile(pluginConfigSource, stagedPluginConfigPath);
|
||||||
|
}
|
||||||
|
if (shouldInstallTheme) {
|
||||||
|
const themeSourcePath = bundledAssets.themeSourcePath;
|
||||||
|
if (!themeSourcePath) {
|
||||||
|
throw new Error('Bundled Linux runtime theme asset was not found.');
|
||||||
|
}
|
||||||
|
await mkdir(pathModule.dirname(managedPaths.themePath), { recursive: true });
|
||||||
|
await copyFile(themeSourcePath, stagedThemePath);
|
||||||
|
}
|
||||||
|
if (shouldInstallPluginAssets) {
|
||||||
|
await rm(managedPaths.pluginDir, { recursive: true, force: true });
|
||||||
|
await rm(managedPaths.pluginConfigPath, { force: true });
|
||||||
|
await rename(stagedPluginDir, managedPaths.pluginDir);
|
||||||
|
pluginDirInstalled = true;
|
||||||
|
await rename(stagedPluginConfigPath, managedPaths.pluginConfigPath);
|
||||||
|
pluginConfigInstalled = true;
|
||||||
|
}
|
||||||
|
if (shouldInstallTheme) {
|
||||||
|
await rm(managedPaths.themePath, { force: true });
|
||||||
|
await rename(stagedThemePath, managedPaths.themePath);
|
||||||
|
themeInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: managedPaths.pluginEntrypointPath,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (pluginDirInstalled && !pluginConfigInstalled) {
|
||||||
|
await rm(managedPaths.pluginDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
if (pluginConfigInstalled && !pluginDirInstalled) {
|
||||||
|
await rm(managedPaths.pluginConfigPath, { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
if (themeInstalled) {
|
||||||
|
await rm(managedPaths.themePath, { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
await rm(stagedPluginDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
await rm(stagedPluginConfigPath, { force: true }).catch(() => {});
|
||||||
|
await rm(stagedThemePath, { force: true }).catch(() => {});
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error: errorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { showLogExportErrorDialog, showLogExportSuccessDialog } from './log-export-dialogs';
|
||||||
|
|
||||||
|
test('showLogExportSuccessDialog handles dialog rejection', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
await showLogExportSuccessDialog({
|
||||||
|
zipPath: '/tmp/subminer-logs.zip',
|
||||||
|
showMessageBox: async () => {
|
||||||
|
throw new Error('dialog failed');
|
||||||
|
},
|
||||||
|
showItemInFolder: () => {
|
||||||
|
throw new Error('unexpected shell call');
|
||||||
|
},
|
||||||
|
logWarn: (message) => warnings.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(warnings, ['Failed to show log export success dialog.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showLogExportErrorDialog handles dialog rejection', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
await showLogExportErrorDialog({
|
||||||
|
message: 'export failed',
|
||||||
|
showMessageBox: async () => {
|
||||||
|
throw new Error('dialog failed');
|
||||||
|
},
|
||||||
|
logWarn: (message) => warnings.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(warnings, ['Failed to show log export error dialog.']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron';
|
||||||
|
|
||||||
|
type ShowMessageBox = (options: MessageBoxOptions) => Promise<MessageBoxReturnValue>;
|
||||||
|
|
||||||
|
export async function showLogExportSuccessDialog(options: {
|
||||||
|
zipPath: string;
|
||||||
|
showMessageBox: ShowMessageBox;
|
||||||
|
showItemInFolder: (path: string) => void;
|
||||||
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
const successDialog = await options
|
||||||
|
.showMessageBox({
|
||||||
|
type: 'info',
|
||||||
|
title: 'SubMiner logs exported',
|
||||||
|
message: 'SubMiner log export created.',
|
||||||
|
detail: options.zipPath,
|
||||||
|
buttons: ['OK', 'Show in Folder'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 0,
|
||||||
|
})
|
||||||
|
.catch((dialogError) => {
|
||||||
|
options.logWarn('Failed to show log export success dialog.', dialogError);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (successDialog?.response === 1) {
|
||||||
|
options.showItemInFolder(options.zipPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showLogExportErrorDialog(options: {
|
||||||
|
message: string;
|
||||||
|
showMessageBox: ShowMessageBox;
|
||||||
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
await options
|
||||||
|
.showMessageBox({
|
||||||
|
type: 'error',
|
||||||
|
title: 'SubMiner log export failed',
|
||||||
|
message: 'Could not export SubMiner logs.',
|
||||||
|
detail: options.message,
|
||||||
|
})
|
||||||
|
.catch((dialogError) => {
|
||||||
|
options.logWarn('Failed to show log export error dialog.', dialogError);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { app, dialog, shell } from 'electron';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { showLogExportErrorDialog, showLogExportSuccessDialog } from './log-export-dialogs';
|
||||||
|
import { exportLogsArchive } from './log-export';
|
||||||
|
|
||||||
|
export interface LogExportTrayRuntimeDeps {
|
||||||
|
flushMpvLog: () => Promise<void>;
|
||||||
|
logInfo: (message: string) => void;
|
||||||
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogExportTrayRuntime(deps: LogExportTrayRuntimeDeps): {
|
||||||
|
exportLogsFromTray: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
function describeUnknownError(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportLogsFromTray(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await deps.flushMpvLog();
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn('Failed to flush mpv log before exporting logs from tray.', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = exportLogsArchive({
|
||||||
|
platform: process.platform,
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
appDataDir: app.getPath('appData'),
|
||||||
|
});
|
||||||
|
deps.logInfo(
|
||||||
|
`Exported ${result.exportedFiles.length} sanitized log file(s) to ${result.zipPath}`,
|
||||||
|
);
|
||||||
|
await showLogExportSuccessDialog({
|
||||||
|
zipPath: result.zipPath,
|
||||||
|
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||||
|
showItemInFolder: (zipPath) => shell.showItemInFolder(zipPath),
|
||||||
|
logWarn: deps.logWarn,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = describeUnknownError(error);
|
||||||
|
deps.logWarn('Failed to export logs from tray.', error);
|
||||||
|
await showLogExportErrorDialog({
|
||||||
|
message,
|
||||||
|
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||||
|
logWarn: deps.logWarn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exportLogsFromTray };
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { type BrowserWindow, screen } from 'electron';
|
||||||
|
import type { WindowGeometry } from '../../types';
|
||||||
|
import { hasHyprlandWindowPlacementBoundsMismatch } from '../../core/services/hyprland-window-placement';
|
||||||
|
import { normalizeOverlayWindowBoundsForPlatform } from '../../core/services/overlay-window-bounds';
|
||||||
|
import {
|
||||||
|
enforceOverlayLayerOrder as enforceOverlayLayerOrderCore,
|
||||||
|
ensureOverlayWindowLevel as ensureOverlayWindowLevelCore,
|
||||||
|
syncOverlayWindowLayer,
|
||||||
|
} from '../../core/services/overlay-window';
|
||||||
|
import { promoteStatsOverlayAbovePlayback } from '../../core/services/stats-window.js';
|
||||||
|
import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape';
|
||||||
|
import { shouldRunLinuxOverlayZOrderKeepAlive } from './linux-overlay-zorder-keepalive';
|
||||||
|
import {
|
||||||
|
shouldExitFullscreenOverrideForTrackedGeometry,
|
||||||
|
type LinuxVisibleOverlayWindowMode,
|
||||||
|
} from './linux-visible-overlay-window-mode';
|
||||||
|
import {
|
||||||
|
createEnforceOverlayLayerOrderHandler,
|
||||||
|
createEnsureOverlayWindowLevelHandler,
|
||||||
|
createUpdateVisibleOverlayBoundsHandler,
|
||||||
|
hasLiveOverlayWindowBoundsMismatch,
|
||||||
|
} from './overlay-window-layout';
|
||||||
|
import {
|
||||||
|
createBuildEnforceOverlayLayerOrderMainDepsHandler,
|
||||||
|
createBuildEnsureOverlayWindowLevelMainDepsHandler,
|
||||||
|
createBuildUpdateVisibleOverlayBoundsMainDepsHandler,
|
||||||
|
} from './overlay-window-layout-main-deps';
|
||||||
|
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order';
|
||||||
|
|
||||||
|
const LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS = 1_200;
|
||||||
|
|
||||||
|
export interface OverlayGeometryRuntimeDeps {
|
||||||
|
overlayManager: {
|
||||||
|
getMainWindow: () => BrowserWindow | null;
|
||||||
|
getModalWindow: () => BrowserWindow | null;
|
||||||
|
getVisibleOverlayVisible: () => boolean;
|
||||||
|
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
|
||||||
|
setModalWindowBounds: (geometry: WindowGeometry) => void;
|
||||||
|
};
|
||||||
|
getTrackedWindowGeometry: () => WindowGeometry | null;
|
||||||
|
getTrackedWindowMediaSourceId: () => string | null | undefined;
|
||||||
|
getTrackedWindowNativeId: () => string | null | undefined;
|
||||||
|
getStatsOverlayVisible: () => boolean;
|
||||||
|
getOverlayForegroundSeparateWindows: () => BrowserWindow[];
|
||||||
|
getLinuxVisibleOverlayWindowMode: () => LinuxVisibleOverlayWindowMode;
|
||||||
|
getLinuxTrackedMpvFullscreen: () => boolean;
|
||||||
|
getLinuxTrackedMpvFullscreenChangedAtMs: () => number;
|
||||||
|
syncLinuxVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) => void;
|
||||||
|
getLinuxVisibleOverlayOwnerBindingKey: () => string | null;
|
||||||
|
setLinuxVisibleOverlayOwnerBindingKey: (key: string | null) => void;
|
||||||
|
clearVisibleOverlayX11OwnerBinding: (window: BrowserWindow) => void;
|
||||||
|
getNativeWindowHandleDecimal: (window: BrowserWindow) => string;
|
||||||
|
enqueueVisibleOverlayX11OwnerBindingOperation: (
|
||||||
|
window: BrowserWindow,
|
||||||
|
args: string[],
|
||||||
|
onError?: (error: Error) => void,
|
||||||
|
) => void;
|
||||||
|
scheduleWindowsVisibleOverlayZOrderSyncBurst: () => void;
|
||||||
|
logDebug: (message: string, ...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOverlayGeometryRuntime(deps: OverlayGeometryRuntimeDeps) {
|
||||||
|
const { overlayManager } = deps;
|
||||||
|
|
||||||
|
let lastOverlayWindowGeometry: WindowGeometry | null = null;
|
||||||
|
|
||||||
|
function getOverlayGeometryFallback(): WindowGeometry {
|
||||||
|
const cursorPoint = screen.getCursorScreenPoint();
|
||||||
|
const display = screen.getDisplayNearestPoint(cursorPoint);
|
||||||
|
const bounds = display.workArea;
|
||||||
|
return {
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentOverlayGeometry(): WindowGeometry {
|
||||||
|
if (lastOverlayWindowGeometry) return lastOverlayWindowGeometry;
|
||||||
|
const trackerGeometry = deps.getTrackedWindowGeometry();
|
||||||
|
if (trackerGeometry) return trackerGeometry;
|
||||||
|
return getOverlayGeometryFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentTrackedOverlayGeometry(): WindowGeometry | null {
|
||||||
|
return deps.getTrackedWindowGeometry();
|
||||||
|
}
|
||||||
|
|
||||||
|
function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOverlayRegions(geometry: WindowGeometry): void {
|
||||||
|
lastOverlayWindowGeometry = geometry;
|
||||||
|
maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry);
|
||||||
|
overlayManager.setOverlayWindowBounds(geometry);
|
||||||
|
overlayManager.setModalWindowBounds(geometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldExitLinuxFullscreenOverrideForGeometry(geometry: WindowGeometry): boolean {
|
||||||
|
if (!shouldRunLinuxOverlayZOrderKeepAlive()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
deps.getLinuxTrackedMpvFullscreenChangedAtMs() > 0 &&
|
||||||
|
Date.now() - deps.getLinuxTrackedMpvFullscreenChangedAtMs() <
|
||||||
|
LINUX_VISIBLE_OVERLAY_FULLSCREEN_GEOMETRY_GRACE_MS
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayBounds = screen.getDisplayMatching(geometry).bounds;
|
||||||
|
return shouldExitFullscreenOverrideForTrackedGeometry({
|
||||||
|
currentMode: deps.getLinuxVisibleOverlayWindowMode(),
|
||||||
|
trackedFullscreen: deps.getLinuxTrackedMpvFullscreen(),
|
||||||
|
geometry,
|
||||||
|
displayBounds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry: WindowGeometry): void {
|
||||||
|
if (!shouldExitLinuxFullscreenOverrideForGeometry(geometry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.logDebug(
|
||||||
|
'Tracked mpv geometry no longer covers its display; exiting Linux fullscreen overlay override',
|
||||||
|
);
|
||||||
|
deps.syncLinuxVisibleOverlayMpvFullscreenMode(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasHyprlandOverlayWindowPlacementMismatch(geometry: WindowGeometry): boolean {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [overlayManager.getMainWindow(), overlayManager.getModalWindow()].some((window) => {
|
||||||
|
if (!window || window.isDestroyed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hasHyprlandWindowPlacementBoundsMismatch({
|
||||||
|
title: window.getTitle(),
|
||||||
|
bounds: normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen, window),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
|
||||||
|
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||||
|
getCurrentOverlayWindowBounds: () => lastOverlayWindowGeometry,
|
||||||
|
shouldRefreshUnchangedGeometry: (geometry) =>
|
||||||
|
shouldExitLinuxFullscreenOverrideForGeometry(geometry) ||
|
||||||
|
(process.platform === 'linux' &&
|
||||||
|
(hasLiveOverlayWindowBoundsMismatch(
|
||||||
|
[overlayManager.getMainWindow(), overlayManager.getModalWindow()],
|
||||||
|
geometry,
|
||||||
|
) ||
|
||||||
|
hasHyprlandOverlayWindowPlacementMismatch(geometry))),
|
||||||
|
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
||||||
|
afterSetOverlayWindowBounds: () => {
|
||||||
|
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
deps.scheduleWindowsVisibleOverlayZOrderSyncBurst();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
restoreLinuxOverlayWindowShape(mainWindow);
|
||||||
|
}
|
||||||
|
ensureOverlayWindowLevel(mainWindow);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
|
||||||
|
const updateVisibleOverlayBounds = createUpdateVisibleOverlayBoundsHandler(
|
||||||
|
updateVisibleOverlayBoundsMainDeps,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildEnsureOverlayWindowLevelMainDepsHandler =
|
||||||
|
createBuildEnsureOverlayWindowLevelMainDepsHandler({
|
||||||
|
shouldSuppressOverlayWindowLevel: (window) => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
return (
|
||||||
|
(deps.getStatsOverlayVisible() && window === mainWindow) ||
|
||||||
|
shouldSuppressVisibleOverlayRaiseForSeparateWindow({
|
||||||
|
window,
|
||||||
|
mainWindow,
|
||||||
|
separateWindows: deps.getOverlayForegroundSeparateWindows(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevelCore: (window) =>
|
||||||
|
ensureOverlayWindowLevelCore(window as BrowserWindow),
|
||||||
|
afterEnsureOverlayWindowLevel: () => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
moveVisibleOverlayAboveTrackedPlaybackWindow(mainWindow);
|
||||||
|
}
|
||||||
|
promoteStatsOverlayAbovePlayback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ensureOverlayWindowLevelMainDeps = buildEnsureOverlayWindowLevelMainDepsHandler();
|
||||||
|
const ensureOverlayWindowLevel = createEnsureOverlayWindowLevelHandler(
|
||||||
|
ensureOverlayWindowLevelMainDeps,
|
||||||
|
);
|
||||||
|
|
||||||
|
function syncPrimaryOverlayWindowLayer(layer: 'visible'): void {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
syncOverlayWindowLayer(mainWindow, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveVisibleOverlayAboveTrackedPlaybackWindow(window: BrowserWindow): void {
|
||||||
|
if (process.platform !== 'linux') return;
|
||||||
|
if (window !== overlayManager.getMainWindow()) return;
|
||||||
|
|
||||||
|
bindVisibleOverlayToTrackedX11Window(window);
|
||||||
|
|
||||||
|
const mediaSourceId = deps.getTrackedWindowMediaSourceId();
|
||||||
|
if (!mediaSourceId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.moveAbove(mediaSourceId);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logDebug(
|
||||||
|
'Failed to move visible overlay above tracked playback window:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindVisibleOverlayToTrackedX11Window(window: BrowserWindow): void {
|
||||||
|
const targetWindowId = deps.getTrackedWindowNativeId();
|
||||||
|
if (!targetWindowId) {
|
||||||
|
if (deps.getLinuxVisibleOverlayOwnerBindingKey() !== null) {
|
||||||
|
deps.clearVisibleOverlayX11OwnerBinding(window);
|
||||||
|
}
|
||||||
|
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayWindowId = deps.getNativeWindowHandleDecimal(window);
|
||||||
|
const bindingKey = `${overlayWindowId}:${targetWindowId}`;
|
||||||
|
if (deps.getLinuxVisibleOverlayOwnerBindingKey() === bindingKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.setLinuxVisibleOverlayOwnerBindingKey(bindingKey);
|
||||||
|
|
||||||
|
deps.enqueueVisibleOverlayX11OwnerBindingOperation(
|
||||||
|
window,
|
||||||
|
[
|
||||||
|
'-id',
|
||||||
|
overlayWindowId,
|
||||||
|
'-f',
|
||||||
|
'WM_TRANSIENT_FOR',
|
||||||
|
'32x',
|
||||||
|
'-set',
|
||||||
|
'WM_TRANSIENT_FOR',
|
||||||
|
targetWindowId,
|
||||||
|
],
|
||||||
|
(error) => {
|
||||||
|
if (deps.getLinuxVisibleOverlayOwnerBindingKey() === bindingKey) {
|
||||||
|
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
|
||||||
|
}
|
||||||
|
deps.logDebug(
|
||||||
|
'Failed to bind visible overlay as transient for tracked X11 playback window:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEnforceOverlayLayerOrderMainDepsHandler =
|
||||||
|
createBuildEnforceOverlayLayerOrderMainDepsHandler({
|
||||||
|
enforceOverlayLayerOrderCore: (params) =>
|
||||||
|
enforceOverlayLayerOrderCore({
|
||||||
|
visibleOverlayVisible: params.visibleOverlayVisible,
|
||||||
|
mainWindow: params.mainWindow as BrowserWindow | null,
|
||||||
|
ensureOverlayWindowLevel: (window) =>
|
||||||
|
params.ensureOverlayWindowLevel(window as BrowserWindow),
|
||||||
|
}),
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window as BrowserWindow),
|
||||||
|
});
|
||||||
|
const enforceOverlayLayerOrderMainDeps = buildEnforceOverlayLayerOrderMainDepsHandler();
|
||||||
|
const enforceOverlayLayerOrder = createEnforceOverlayLayerOrderHandler(
|
||||||
|
enforceOverlayLayerOrderMainDeps,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getLastOverlayWindowGeometry: () => lastOverlayWindowGeometry,
|
||||||
|
resetLastOverlayWindowGeometry: () => {
|
||||||
|
lastOverlayWindowGeometry = null;
|
||||||
|
},
|
||||||
|
getOverlayGeometryFallback,
|
||||||
|
getCurrentOverlayGeometry,
|
||||||
|
getCurrentTrackedOverlayGeometry,
|
||||||
|
geometryMatches,
|
||||||
|
applyOverlayRegions,
|
||||||
|
shouldExitLinuxFullscreenOverrideForGeometry,
|
||||||
|
maybeExitLinuxFullscreenOverrideForTrackedGeometry,
|
||||||
|
hasHyprlandOverlayWindowPlacementMismatch,
|
||||||
|
moveVisibleOverlayAboveTrackedPlaybackWindow,
|
||||||
|
bindVisibleOverlayToTrackedX11Window,
|
||||||
|
syncPrimaryOverlayWindowLayer,
|
||||||
|
updateVisibleOverlayBounds,
|
||||||
|
ensureOverlayWindowLevel,
|
||||||
|
enforceOverlayLayerOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OverlayGeometryRuntime = ReturnType<typeof createOverlayGeometryRuntime>;
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import type {
|
||||||
|
NotificationType,
|
||||||
|
OverlayNotificationEventPayload,
|
||||||
|
OverlayNotificationPayload,
|
||||||
|
ResolvedConfig,
|
||||||
|
} from '../../types';
|
||||||
|
import type { AnkiIntegration } from '../../anki-integration';
|
||||||
|
import type { RuntimeOptionsManager } from '../../runtime-options';
|
||||||
|
import { AnkiConnectClient } from '../../anki-connect';
|
||||||
|
import { DEFAULT_CONFIG } from '../../config';
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
import { showDesktopNotification } from '../../core/utils';
|
||||||
|
import {
|
||||||
|
isOverlayWindowContentReady,
|
||||||
|
sendMpvCommandRuntime,
|
||||||
|
type MpvIpcClient,
|
||||||
|
} from '../../core/services';
|
||||||
|
import { createOverlayLoadingOsdController } from './overlay-loading-osd';
|
||||||
|
import { createMaybeStartOverlayLoadingOsdHandler } from './overlay-loading-osd-start';
|
||||||
|
import { withConfiguredOverlayNotificationPosition } from './overlay-notification-position';
|
||||||
|
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
||||||
|
import {
|
||||||
|
getPlaybackFeedbackNotificationOptions,
|
||||||
|
notifyConfiguredStatus,
|
||||||
|
type ConfiguredStatusNotificationOptions,
|
||||||
|
} from './configured-status-notification';
|
||||||
|
import { resolveOverlayReadinessNotificationType } from './notification-routing';
|
||||||
|
|
||||||
|
export interface OverlayNotificationsRuntimeDeps {
|
||||||
|
getResolvedConfig: () => ResolvedConfig;
|
||||||
|
getMainOverlayWindow: () => BrowserWindow | null;
|
||||||
|
getVisibleOverlayVisible: () => boolean;
|
||||||
|
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
||||||
|
showMpvOsd: (message: string) => void;
|
||||||
|
getMpvClient: () => MpvIpcClient | null;
|
||||||
|
getAnkiIntegration: () => AnkiIntegration | null;
|
||||||
|
getRuntimeOptionsManager: () => RuntimeOptionsManager | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRuntimeDeps): {
|
||||||
|
isVisibleOverlayContentReady: () => boolean;
|
||||||
|
getConfiguredStatusNotificationType: () => NotificationType;
|
||||||
|
flushQueuedOverlayNotifications: () => void;
|
||||||
|
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
|
||||||
|
dismissOverlayNotification: (id: string) => void;
|
||||||
|
openAnkiCardFromNotification: (noteId: number) => Promise<void>;
|
||||||
|
toggleNotificationHistoryPanel: () => void;
|
||||||
|
showConfiguredStatusNotification: (
|
||||||
|
message: string,
|
||||||
|
options?: ConfiguredStatusNotificationOptions,
|
||||||
|
) => void;
|
||||||
|
showConfiguredPlaybackFeedback: (
|
||||||
|
message: string,
|
||||||
|
options?: ConfiguredStatusNotificationOptions,
|
||||||
|
) => void;
|
||||||
|
showSubsyncStatusNotification: (message: string) => void;
|
||||||
|
showYoutubeFlowStatusNotification: (message: string) => void;
|
||||||
|
showOverlayLoadingStatusNotification: () => void;
|
||||||
|
dismissOverlayLoadingStatusNotification: () => void;
|
||||||
|
maybeStartOverlayLoadingOsd: (mediaPath?: string | null) => void;
|
||||||
|
} {
|
||||||
|
function isVisibleOverlayContentReady(): boolean {
|
||||||
|
const overlayWindow = deps.getMainOverlayWindow();
|
||||||
|
return Boolean(
|
||||||
|
deps.getVisibleOverlayVisible() &&
|
||||||
|
overlayWindow &&
|
||||||
|
isOverlayWindowReadyForNotification(overlayWindow),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredStatusNotificationType(): NotificationType {
|
||||||
|
const configuredType = deps.getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||||
|
return resolveOverlayReadinessNotificationType(configuredType, isVisibleOverlayContentReady());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverlayWindowReadyForNotification(window: BrowserWindow): boolean {
|
||||||
|
if (window.isDestroyed() || !isOverlayWindowContentReady(window)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (window.webContents.isLoading()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const currentURL = window.webContents.getURL();
|
||||||
|
return currentURL !== '' && currentURL !== 'about:blank';
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayNotificationDelivery = createOverlayNotificationDelivery({
|
||||||
|
hasReadyOverlayWindow: () => isVisibleOverlayContentReady(),
|
||||||
|
send: (payload) => {
|
||||||
|
deps.broadcastToOverlayWindows(IPC_CHANNELS.event.overlayNotification, payload);
|
||||||
|
},
|
||||||
|
scheduleFlushRetry: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||||
|
clearFlushRetry: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||||
|
});
|
||||||
|
let overlayLoadingOsdController: ReturnType<typeof createOverlayLoadingOsdController> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
function flushQueuedOverlayNotifications(): void {
|
||||||
|
overlayNotificationDelivery.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendOverlayNotificationEvent(payload: OverlayNotificationEventPayload): void {
|
||||||
|
overlayNotificationDelivery.send(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showOverlayNotification(payload: OverlayNotificationPayload): void {
|
||||||
|
sendOverlayNotificationEvent(
|
||||||
|
withConfiguredOverlayNotificationPosition(payload, deps.getResolvedConfig()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissOverlayNotification(id: string): void {
|
||||||
|
sendOverlayNotificationEvent({ id, dismiss: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAnkiCardFromNotification(noteId: number): Promise<void> {
|
||||||
|
const activeIntegrationOpen = deps.getAnkiIntegration()?.openNoteInAnki(noteId);
|
||||||
|
if (activeIntegrationOpen) {
|
||||||
|
await activeIntegrationOpen;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedConfig = deps.getResolvedConfig();
|
||||||
|
const effectiveAnkiConfig =
|
||||||
|
deps.getRuntimeOptionsManager()?.getEffectiveAnkiConnectConfig(resolvedConfig.ankiConnect) ??
|
||||||
|
resolvedConfig.ankiConnect;
|
||||||
|
const fallbackClient = new AnkiConnectClient(
|
||||||
|
effectiveAnkiConfig.url || DEFAULT_CONFIG.ankiConnect.url,
|
||||||
|
);
|
||||||
|
await fallbackClient.openNoteInBrowser(noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNotificationHistoryPanel(): void {
|
||||||
|
deps.broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConfiguredStatusNotification(
|
||||||
|
message: string,
|
||||||
|
options: ConfiguredStatusNotificationOptions = {},
|
||||||
|
): void {
|
||||||
|
notifyConfiguredStatus(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
getNotificationType: () => deps.getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||||
|
isOverlayReady: () => isVisibleOverlayContentReady(),
|
||||||
|
showOsd: (text) => deps.showMpvOsd(text),
|
||||||
|
showOverlayNotification,
|
||||||
|
showDesktopNotification: (title, notificationOptions) =>
|
||||||
|
showDesktopNotification(title, notificationOptions),
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConfiguredPlaybackFeedback(
|
||||||
|
message: string,
|
||||||
|
options: ConfiguredStatusNotificationOptions = {},
|
||||||
|
): void {
|
||||||
|
showConfiguredStatusNotification(message, {
|
||||||
|
...getPlaybackFeedbackNotificationOptions(message),
|
||||||
|
...options,
|
||||||
|
delivery: 'feedback',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSubsyncStatusNotification(message: string): void {
|
||||||
|
const syncing = message.startsWith('Subsync: syncing');
|
||||||
|
const failed = message.toLowerCase().includes('failed');
|
||||||
|
showConfiguredStatusNotification(message, {
|
||||||
|
id: 'subsync-status',
|
||||||
|
title: 'Subsync',
|
||||||
|
variant: failed ? 'error' : syncing ? 'progress' : 'info',
|
||||||
|
persistent: syncing,
|
||||||
|
desktop: !syncing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showYoutubeFlowStatusNotification(message: string): void {
|
||||||
|
const progress =
|
||||||
|
message.startsWith('Downloading subtitles') ||
|
||||||
|
message.startsWith('Loading subtitles') ||
|
||||||
|
message.startsWith('Getting subtitles') ||
|
||||||
|
message === 'Opening YouTube video';
|
||||||
|
showConfiguredStatusNotification(message, {
|
||||||
|
id: 'youtube-subtitles-status',
|
||||||
|
title: 'YouTube subtitles',
|
||||||
|
variant: progress ? 'progress' : 'info',
|
||||||
|
persistent: progress,
|
||||||
|
desktop: !progress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
|
||||||
|
if (!overlayLoadingOsdController) {
|
||||||
|
overlayLoadingOsdController = createOverlayLoadingOsdController({
|
||||||
|
showOsd: (message) => {
|
||||||
|
deps.showMpvOsd(message);
|
||||||
|
},
|
||||||
|
clearOsd: () => {
|
||||||
|
sendMpvCommandRuntime(deps.getMpvClient(), ['show-text', '', '1']);
|
||||||
|
},
|
||||||
|
setInterval: (callback, delayMs) => {
|
||||||
|
const timer = setInterval(callback, delayMs);
|
||||||
|
timer.unref?.();
|
||||||
|
return timer;
|
||||||
|
},
|
||||||
|
clearInterval: (timer) => {
|
||||||
|
clearInterval(timer as ReturnType<typeof setInterval>);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return overlayLoadingOsdController;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showOverlayLoadingStatusNotification(): void {
|
||||||
|
getOverlayLoadingOsdController().start();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissOverlayLoadingStatusNotification(): void {
|
||||||
|
getOverlayLoadingOsdController().stop();
|
||||||
|
sendMpvCommandRuntime(deps.getMpvClient(), [
|
||||||
|
'script-message',
|
||||||
|
'subminer-overlay-loading-ready',
|
||||||
|
]);
|
||||||
|
dismissOverlayNotification('overlay-loading-status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeStartOverlayLoadingOsd = createMaybeStartOverlayLoadingOsdHandler({
|
||||||
|
getVisibleOverlayRequested: () => deps.getVisibleOverlayVisible(),
|
||||||
|
isOverlayContentReady: () => isVisibleOverlayContentReady(),
|
||||||
|
startOverlayLoadingOsd: () => {
|
||||||
|
showOverlayLoadingStatusNotification();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isVisibleOverlayContentReady,
|
||||||
|
getConfiguredStatusNotificationType,
|
||||||
|
flushQueuedOverlayNotifications,
|
||||||
|
showOverlayNotification,
|
||||||
|
dismissOverlayNotification,
|
||||||
|
openAnkiCardFromNotification,
|
||||||
|
toggleNotificationHistoryPanel,
|
||||||
|
showConfiguredStatusNotification,
|
||||||
|
showConfiguredPlaybackFeedback,
|
||||||
|
showSubsyncStatusNotification,
|
||||||
|
showYoutubeFlowStatusNotification,
|
||||||
|
showOverlayLoadingStatusNotification,
|
||||||
|
dismissOverlayLoadingStatusNotification,
|
||||||
|
maybeStartOverlayLoadingOsd,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { CompiledSessionBinding, ResolvedConfig } from '../../types';
|
||||||
|
import { createSessionBindingsRuntime } from './session-bindings-runtime';
|
||||||
|
|
||||||
|
test('persistSessionBindings logs and does not publish bindings when artifact write fails', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-bindings-runtime-'));
|
||||||
|
const configDir = path.join(root, 'config-file');
|
||||||
|
fs.writeFileSync(configDir, 'not a directory');
|
||||||
|
const calls: string[] = [];
|
||||||
|
const runtime = createSessionBindingsRuntime({
|
||||||
|
configDir,
|
||||||
|
getKeybindings: () => [],
|
||||||
|
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
|
||||||
|
getResolvedConfig: () =>
|
||||||
|
({
|
||||||
|
stats: { toggleKey: 's', markWatchedKey: 'w' },
|
||||||
|
}) as ResolvedConfig,
|
||||||
|
getMpvClient: () => null,
|
||||||
|
setSessionBindings: () => calls.push('setSessionBindings'),
|
||||||
|
setSessionBindingsInitialized: () => calls.push('setSessionBindingsInitialized'),
|
||||||
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.throws(
|
||||||
|
() => runtime.persistSessionBindings([] as CompiledSessionBinding[]),
|
||||||
|
/ENOTDIR|EEXIST/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls, ['warn:[session-bindings] Failed to write session bindings artifact']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persistSessionBindings keeps saved bindings when mpv reload notification fails', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-bindings-runtime-'));
|
||||||
|
const calls: string[] = [];
|
||||||
|
const runtime = createSessionBindingsRuntime({
|
||||||
|
configDir: root,
|
||||||
|
getKeybindings: () => [],
|
||||||
|
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
|
||||||
|
getResolvedConfig: () =>
|
||||||
|
({
|
||||||
|
stats: { toggleKey: 's', markWatchedKey: 'w' },
|
||||||
|
}) as ResolvedConfig,
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
connected: true,
|
||||||
|
send: () => {
|
||||||
|
throw new Error('mpv unavailable');
|
||||||
|
},
|
||||||
|
}) as never,
|
||||||
|
setSessionBindings: () => calls.push('setSessionBindings'),
|
||||||
|
setSessionBindingsInitialized: () => calls.push('setSessionBindingsInitialized'),
|
||||||
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.doesNotThrow(() => runtime.persistSessionBindings([] as CompiledSessionBinding[]));
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'setSessionBindings',
|
||||||
|
'setSessionBindingsInitialized',
|
||||||
|
'warn:[session-bindings] Failed to notify mpv to reload session bindings',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { sendMpvCommandRuntime, type MpvRuntimeClientLike } from '../../core/services';
|
||||||
|
import {
|
||||||
|
buildPluginSessionBindingsArtifact,
|
||||||
|
compileSessionBindings,
|
||||||
|
} from '../../core/services/session-bindings';
|
||||||
|
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||||
|
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
|
||||||
|
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
|
||||||
|
|
||||||
|
export interface SessionBindingsRuntimeDeps {
|
||||||
|
configDir: string;
|
||||||
|
getKeybindings: () => Keybinding[];
|
||||||
|
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
||||||
|
getResolvedConfig: () => ResolvedConfig;
|
||||||
|
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||||
|
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
|
||||||
|
setSessionBindingsInitialized: (initialized: boolean) => void;
|
||||||
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
||||||
|
persistSessionBindings: (
|
||||||
|
bindings: CompiledSessionBinding[],
|
||||||
|
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
|
||||||
|
) => void;
|
||||||
|
refreshCurrentSessionBindings: () => void;
|
||||||
|
} {
|
||||||
|
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
|
||||||
|
if (process.platform === 'darwin') return 'darwin';
|
||||||
|
if (process.platform === 'win32') return 'win32';
|
||||||
|
return 'linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileCurrentSessionBindings(): {
|
||||||
|
bindings: CompiledSessionBinding[];
|
||||||
|
warnings: ReturnType<typeof compileSessionBindings>['warnings'];
|
||||||
|
} {
|
||||||
|
return compileSessionBindings({
|
||||||
|
keybindings: deps.getKeybindings(),
|
||||||
|
shortcuts: deps.getConfiguredShortcuts(),
|
||||||
|
statsToggleKey: deps.getResolvedConfig().stats.toggleKey,
|
||||||
|
statsMarkWatchedKey: deps.getResolvedConfig().stats.markWatchedKey,
|
||||||
|
platform: resolveSessionBindingPlatform(),
|
||||||
|
rawConfig: deps.getResolvedConfig(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSessionBindings(
|
||||||
|
bindings: CompiledSessionBinding[],
|
||||||
|
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
||||||
|
): void {
|
||||||
|
const artifact = buildPluginSessionBindingsArtifact({
|
||||||
|
bindings,
|
||||||
|
warnings,
|
||||||
|
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
writeSessionBindingsArtifact(deps.configDir, artifact);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
deps.setSessionBindings(bindings);
|
||||||
|
deps.setSessionBindingsInitialized(true);
|
||||||
|
const mpvClient = deps.getMpvClient();
|
||||||
|
if (mpvClient?.connected) {
|
||||||
|
try {
|
||||||
|
sendMpvCommandRuntime(mpvClient, ['script-message', 'subminer-reload-session-bindings']);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCurrentSessionBindings(): void {
|
||||||
|
const compiled = compileCurrentSessionBindings();
|
||||||
|
for (const warning of compiled.warnings) {
|
||||||
|
deps.logWarn(`[session-bindings] ${warning.message}`);
|
||||||
|
}
|
||||||
|
persistSessionBindings(compiled.bindings, compiled.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { persistSessionBindings, refreshCurrentSessionBindings };
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
|||||||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
|
||||||
initialArgs?.settings ||
|
initialArgs?.settings ||
|
||||||
initialArgs?.update ||
|
initialArgs?.update ||
|
||||||
|
initialArgs?.ensureLinuxRuntimePluginAssets ||
|
||||||
(initialArgs?.stats &&
|
(initialArgs?.stats &&
|
||||||
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
|
||||||
),
|
),
|
||||||
@@ -24,6 +25,7 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
|
|||||||
initialArgs.stats ||
|
initialArgs.stats ||
|
||||||
initialArgs.dictionary ||
|
initialArgs.dictionary ||
|
||||||
initialArgs.update ||
|
initialArgs.update ||
|
||||||
|
initialArgs.ensureLinuxRuntimePluginAssets ||
|
||||||
initialArgs.setup),
|
initialArgs.setup),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
export type BackgroundStatsServerState = {
|
export type BackgroundStatsServerState = {
|
||||||
@@ -65,6 +66,43 @@ export function isBackgroundStatsServerProcessAlive(pid: number): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readProcessStartedAtMs(pid: number): number | null {
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const output = execFileSync(
|
||||||
|
'powershell.exe',
|
||||||
|
[
|
||||||
|
'-NoProfile',
|
||||||
|
'-Command',
|
||||||
|
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate.ToUniversalTime().ToString("o")`,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8', timeout: 1000 },
|
||||||
|
).trim();
|
||||||
|
const parsed = Date.parse(output);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 1000,
|
||||||
|
}).trim();
|
||||||
|
const parsed = Date.parse(output);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyBackgroundStatsServerIdentity(pid: number, startedAtMs: number): boolean {
|
||||||
|
const processStartedAtMs = readProcessStartedAtMs(pid);
|
||||||
|
if (processStartedAtMs === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const earliestAllowedStateWriteMs = processStartedAtMs;
|
||||||
|
const latestAllowedStateWriteMs = processStartedAtMs + 60_000;
|
||||||
|
return startedAtMs >= earliestAllowedStateWriteMs && startedAtMs <= latestAllowedStateWriteMs;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveBackgroundStatsServerUrl(
|
export function resolveBackgroundStatsServerUrl(
|
||||||
state: Pick<BackgroundStatsServerState, 'port'>,
|
state: Pick<BackgroundStatsServerState, 'port'>,
|
||||||
): string {
|
): string {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
createStatsServerRuntime,
|
||||||
|
isSelfOwnedBackgroundStatsDaemonState,
|
||||||
|
shouldClearAppStateStatsServerOnStop,
|
||||||
|
} from './stats-server-runtime';
|
||||||
|
|
||||||
|
test('detects self-owned background stats daemon state', () => {
|
||||||
|
assert.equal(
|
||||||
|
isSelfOwnedBackgroundStatsDaemonState({ pid: process.pid, port: 6969, startedAtMs: 1 }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stats server app-state reference should be cleared after private server stop', () => {
|
||||||
|
assert.equal(shouldClearAppStateStatsServerOnStop({ hadStatsServer: true }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stopBackgroundStatsServer clears stale state when daemon identity mismatches', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const runtime = createStatsServerRuntime({
|
||||||
|
userDataPath: '/tmp/subminer-stats-runtime-test',
|
||||||
|
statsDistPath: '/tmp/stats-dist',
|
||||||
|
getResolvedConfig: () => ({ stats: { serverPort: 5175 } }) as never,
|
||||||
|
getImmersionTracker: () => null,
|
||||||
|
setAppStateStatsServer: () => {},
|
||||||
|
getMpvSocketPath: () => '/tmp/mpv.sock',
|
||||||
|
getYomitanExt: () => null,
|
||||||
|
getYomitanSession: () => null,
|
||||||
|
getYomitanParserWindow: () => null,
|
||||||
|
setYomitanParserWindow: () => {},
|
||||||
|
getYomitanParserReadyPromise: () => null,
|
||||||
|
setYomitanParserReadyPromise: () => {},
|
||||||
|
getYomitanParserInitPromise: () => null,
|
||||||
|
setYomitanParserInitPromise: () => {},
|
||||||
|
getYomitanAnkiDeckName: async () => 'Mining',
|
||||||
|
getAnilistRateLimiter: () => ({}) as never,
|
||||||
|
resolveAnkiNoteId: (noteId) => noteId,
|
||||||
|
trackDuplicateNoteIdsForNote: () => {},
|
||||||
|
resolveSentenceSearchHeadwords: async () => [],
|
||||||
|
ensureImmersionTrackerStarted: () => {},
|
||||||
|
setStatsStartupInProgress: () => {},
|
||||||
|
readBackgroundStatsServerState: () => ({ pid: 4242, port: 5175, startedAtMs: 1 }),
|
||||||
|
removeBackgroundStatsServerState: () => {
|
||||||
|
calls.push('removeBackgroundStatsServerState');
|
||||||
|
},
|
||||||
|
isBackgroundStatsServerProcessAlive: () => true,
|
||||||
|
verifyBackgroundStatsServerIdentity: () => false,
|
||||||
|
killProcess: () => {
|
||||||
|
calls.push('killProcess');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.stopBackgroundStatsServer();
|
||||||
|
|
||||||
|
assert.deepEqual(result, { ok: true, stale: true });
|
||||||
|
assert.deepEqual(calls, ['removeBackgroundStatsServerState']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import {
|
||||||
|
addYomitanNoteViaSearch,
|
||||||
|
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
||||||
|
} from '../../core/services';
|
||||||
|
import { startStatsServer } from '../../core/services/stats-server';
|
||||||
|
import { createLogger } from '../../logger';
|
||||||
|
import type { ResolvedConfig } from '../../types/config';
|
||||||
|
import type { AppState } from '../state';
|
||||||
|
import {
|
||||||
|
isBackgroundStatsServerProcessAlive as defaultIsBackgroundStatsServerProcessAlive,
|
||||||
|
readBackgroundStatsServerState as defaultReadBackgroundStatsServerState,
|
||||||
|
removeBackgroundStatsServerState as defaultRemoveBackgroundStatsServerState,
|
||||||
|
resolveBackgroundStatsServerUrl,
|
||||||
|
verifyBackgroundStatsServerIdentity as defaultVerifyBackgroundStatsServerIdentity,
|
||||||
|
writeBackgroundStatsServerState,
|
||||||
|
} from './stats-daemon';
|
||||||
|
import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
|
||||||
|
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
|
||||||
|
|
||||||
|
export function isSelfOwnedBackgroundStatsDaemonState(state: {
|
||||||
|
pid: number;
|
||||||
|
port?: number;
|
||||||
|
startedAtMs?: number;
|
||||||
|
}): boolean {
|
||||||
|
return state.pid === process.pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldClearAppStateStatsServerOnStop(options: {
|
||||||
|
hadStatsServer: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return options.hadStatsServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsServerRuntimeDeps {
|
||||||
|
userDataPath: string;
|
||||||
|
statsDistPath: string;
|
||||||
|
getResolvedConfig: () => ResolvedConfig;
|
||||||
|
getImmersionTracker: () => AppState['immersionTracker'];
|
||||||
|
setAppStateStatsServer: (server: AppState['statsServer']) => void;
|
||||||
|
getMpvSocketPath: () => AppState['mpvSocketPath'];
|
||||||
|
getYomitanExt: () => AppState['yomitanExt'];
|
||||||
|
getYomitanSession: () => AppState['yomitanSession'];
|
||||||
|
getYomitanParserWindow: () => AppState['yomitanParserWindow'];
|
||||||
|
setYomitanParserWindow: (w: BrowserWindow | null) => void;
|
||||||
|
getYomitanParserReadyPromise: () => AppState['yomitanParserReadyPromise'];
|
||||||
|
setYomitanParserReadyPromise: (p: Promise<void> | null) => void;
|
||||||
|
getYomitanParserInitPromise: () => AppState['yomitanParserInitPromise'];
|
||||||
|
setYomitanParserInitPromise: (p: Promise<boolean> | null) => void;
|
||||||
|
getYomitanAnkiDeckName: () => Promise<string>;
|
||||||
|
getAnilistRateLimiter: () => NonNullable<
|
||||||
|
Parameters<typeof startStatsServer>[0]['anilistRateLimiter']
|
||||||
|
>;
|
||||||
|
resolveAnkiNoteId: (noteId: number) => number;
|
||||||
|
trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||||
|
resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>;
|
||||||
|
ensureImmersionTrackerStarted: () => void;
|
||||||
|
setStatsStartupInProgress: (inProgress: boolean) => void;
|
||||||
|
readBackgroundStatsServerState?: typeof defaultReadBackgroundStatsServerState;
|
||||||
|
removeBackgroundStatsServerState?: typeof defaultRemoveBackgroundStatsServerState;
|
||||||
|
isBackgroundStatsServerProcessAlive?: typeof defaultIsBackgroundStatsServerProcessAlive;
|
||||||
|
verifyBackgroundStatsServerIdentity?: typeof defaultVerifyBackgroundStatsServerIdentity;
|
||||||
|
killProcess?: (pid: number, signal: NodeJS.Signals) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||||
|
stopStatsServer: () => void;
|
||||||
|
ensureStatsServerStarted: ReturnType<typeof createEnsureStatsServerUrlHandler>;
|
||||||
|
ensureBackgroundStatsServerStarted: () => {
|
||||||
|
url: string;
|
||||||
|
runningInCurrentProcess: boolean;
|
||||||
|
};
|
||||||
|
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
|
||||||
|
} {
|
||||||
|
let statsServer: ReturnType<typeof startStatsServer> | null = null;
|
||||||
|
const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json');
|
||||||
|
const readDaemonState =
|
||||||
|
deps.readBackgroundStatsServerState ??
|
||||||
|
((statePath: string) => defaultReadBackgroundStatsServerState(statePath));
|
||||||
|
const removeDaemonState =
|
||||||
|
deps.removeBackgroundStatsServerState ??
|
||||||
|
((statePath: string) => defaultRemoveBackgroundStatsServerState(statePath));
|
||||||
|
const isDaemonAlive =
|
||||||
|
deps.isBackgroundStatsServerProcessAlive ??
|
||||||
|
((pid: number) => defaultIsBackgroundStatsServerProcessAlive(pid));
|
||||||
|
const verifyDaemonIdentity =
|
||||||
|
deps.verifyBackgroundStatsServerIdentity ??
|
||||||
|
((pid: number, startedAtMs: number) =>
|
||||||
|
defaultVerifyBackgroundStatsServerIdentity(pid, startedAtMs));
|
||||||
|
const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal));
|
||||||
|
|
||||||
|
function readLiveBackgroundStatsDaemonState(): {
|
||||||
|
pid: number;
|
||||||
|
port: number;
|
||||||
|
startedAtMs: number;
|
||||||
|
} | null {
|
||||||
|
const state = readDaemonState(statsDaemonStatePath);
|
||||||
|
if (!state) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (state.pid === process.pid && !statsServer) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isDaemonAlive(state.pid)) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOwnedBackgroundStatsDaemonState(): void {
|
||||||
|
const state = readDaemonState(statsDaemonStatePath);
|
||||||
|
if (state?.pid === process.pid) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStatsServer(): void {
|
||||||
|
if (!statsServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statsServer.close();
|
||||||
|
statsServer = null;
|
||||||
|
if (shouldClearAppStateStatsServerOnStop({ hadStatsServer: true })) {
|
||||||
|
deps.setAppStateStatsServer(null);
|
||||||
|
}
|
||||||
|
clearOwnedBackgroundStatsDaemonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLocalStatsServer = (): void => {
|
||||||
|
const tracker = deps.getImmersionTracker();
|
||||||
|
if (!tracker) {
|
||||||
|
throw new Error('Immersion tracker failed to initialize.');
|
||||||
|
}
|
||||||
|
if (!statsServer) {
|
||||||
|
const yomitanDeps = {
|
||||||
|
getYomitanExt: () => deps.getYomitanExt(),
|
||||||
|
getYomitanSession: () => deps.getYomitanSession(),
|
||||||
|
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
|
||||||
|
setYomitanParserWindow: (w: BrowserWindow | null) => {
|
||||||
|
deps.setYomitanParserWindow(w);
|
||||||
|
},
|
||||||
|
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
|
||||||
|
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
|
||||||
|
deps.setYomitanParserReadyPromise(p);
|
||||||
|
},
|
||||||
|
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
|
||||||
|
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
|
||||||
|
deps.setYomitanParserInitPromise(p);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const yomitanLogger = createLogger('main:yomitan-stats');
|
||||||
|
statsServer = startStatsServer({
|
||||||
|
port: deps.getResolvedConfig().stats.serverPort,
|
||||||
|
staticDir: deps.statsDistPath,
|
||||||
|
tracker,
|
||||||
|
knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'),
|
||||||
|
mpvSocketPath: deps.getMpvSocketPath(),
|
||||||
|
getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect,
|
||||||
|
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
|
||||||
|
getSecondarySubtitleLanguages: () =>
|
||||||
|
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||||
|
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
|
||||||
|
anilistRateLimiter: deps.getAnilistRateLimiter(),
|
||||||
|
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
|
||||||
|
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
|
||||||
|
addYomitanNote: async (word: string) => {
|
||||||
|
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
||||||
|
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
||||||
|
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||||
|
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
||||||
|
deck: ankiConnectConfig.deck,
|
||||||
|
});
|
||||||
|
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||||
|
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||||
|
deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds);
|
||||||
|
}
|
||||||
|
return result.noteId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
deps.setAppStateStatsServer(statsServer);
|
||||||
|
}
|
||||||
|
deps.setAppStateStatsServer(statsServer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
|
||||||
|
currentPid: process.pid,
|
||||||
|
readBackgroundState: () => readDaemonState(statsDaemonStatePath),
|
||||||
|
removeBackgroundState: () => {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
},
|
||||||
|
isProcessAlive: (pid) => isDaemonAlive(pid),
|
||||||
|
hasLocalStatsServer: () => statsServer !== null,
|
||||||
|
startLocalStatsServer,
|
||||||
|
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensureBackgroundStatsServerStarted = (): {
|
||||||
|
url: string;
|
||||||
|
runningInCurrentProcess: boolean;
|
||||||
|
} => {
|
||||||
|
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
||||||
|
if (liveDaemon && liveDaemon.pid !== process.pid) {
|
||||||
|
return {
|
||||||
|
url: resolveBackgroundStatsServerUrl(liveDaemon),
|
||||||
|
runningInCurrentProcess: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.setStatsStartupInProgress(true);
|
||||||
|
try {
|
||||||
|
deps.ensureImmersionTrackerStarted();
|
||||||
|
} finally {
|
||||||
|
deps.setStatsStartupInProgress(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = deps.getResolvedConfig().stats.serverPort;
|
||||||
|
const result = ensureStatsServerStarted();
|
||||||
|
if (result.source === 'local') {
|
||||||
|
writeBackgroundStatsServerState(statsDaemonStatePath, {
|
||||||
|
pid: process.pid,
|
||||||
|
port,
|
||||||
|
startedAtMs: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
|
||||||
|
const state = readDaemonState(statsDaemonStatePath);
|
||||||
|
if (!state) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
if (isSelfOwnedBackgroundStatsDaemonState(state)) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
if (!isDaemonAlive(state.pid)) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
if (!verifyDaemonIdentity(state.pid, state.startedAtMs)) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
killProcess(state.pid, 'SIGTERM');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
if ((error as NodeJS.ErrnoException)?.code === 'EPERM') {
|
||||||
|
throw new Error(
|
||||||
|
`Insufficient permissions to stop background stats server (pid ${state.pid}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = Date.now() + 2_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!isDaemonAlive(state.pid)) {
|
||||||
|
removeDaemonState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: false };
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Timed out stopping background stats server.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stopStatsServer,
|
||||||
|
ensureStatsServerStarted,
|
||||||
|
ensureBackgroundStatsServerStarted,
|
||||||
|
stopBackgroundStatsServer,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,18 +9,43 @@ import {
|
|||||||
buildProtectedSupportAssetsCommand,
|
buildProtectedSupportAssetsCommand,
|
||||||
detectSupportAssetDataDirs,
|
detectSupportAssetDataDirs,
|
||||||
updateSupportAssetsFromRelease,
|
updateSupportAssetsFromRelease,
|
||||||
|
type SupportAssetsUpdateResult,
|
||||||
} from './support-assets';
|
} from './support-assets';
|
||||||
|
|
||||||
|
type SupportAssetsResultWithComponent = SupportAssetsUpdateResult & {
|
||||||
|
component?: 'theme' | 'plugin';
|
||||||
|
};
|
||||||
|
|
||||||
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 makeSupportAssetsArchive(): { archive: Buffer; tempDir: string } {
|
function makeSupportAssetsArchive(options?: {
|
||||||
|
themeContent?: string;
|
||||||
|
pluginVersion?: string | null;
|
||||||
|
pluginMainContent?: string;
|
||||||
|
extraPluginFiles?: Array<{ relativePath: string; content: string }>;
|
||||||
|
}): { archive: Buffer; tempDir: string } {
|
||||||
|
const themeContent = options?.themeContent ?? 'new theme\n';
|
||||||
|
const pluginVersion = options && 'pluginVersion' in options ? options.pluginVersion : '0.12.0';
|
||||||
|
const pluginMainContent = options?.pluginMainContent ?? 'new plugin\n';
|
||||||
|
const extraPluginFiles = options?.extraPluginFiles ?? [];
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-support-assets-test-'));
|
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, 'assets/themes'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(tempDir, 'plugin/subminer'), { 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, 'assets/themes/subminer.rasi'), themeContent);
|
||||||
fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), 'new plugin\n');
|
fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), pluginMainContent);
|
||||||
|
if (pluginVersion !== null) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tempDir, 'plugin/subminer/version.lua'),
|
||||||
|
`return {\n\tversion = "${pluginVersion}",\n}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const extraFile of extraPluginFiles) {
|
||||||
|
const targetPath = path.join(tempDir, 'plugin/subminer', extraFile.relativePath);
|
||||||
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||||
|
fs.writeFileSync(targetPath, extraFile.content);
|
||||||
|
}
|
||||||
execFileSync('tar', ['-czf', 'subminer-assets.tar.gz', 'assets', 'plugin'], { cwd: tempDir });
|
execFileSync('tar', ['-czf', 'subminer-assets.tar.gz', 'assets', 'plugin'], { cwd: tempDir });
|
||||||
return {
|
return {
|
||||||
archive: fs.readFileSync(path.join(tempDir, 'subminer-assets.tar.gz')),
|
archive: fs.readFileSync(path.join(tempDir, 'subminer-assets.tar.gz')),
|
||||||
@@ -28,7 +53,29 @@ function makeSupportAssetsArchive(): { archive: Buffer; tempDir: string } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('detectSupportAssetDataDirs only returns Linux rofi theme locations', () => {
|
async function runLinuxSupportAssetUpdate(options: {
|
||||||
|
archive: Buffer;
|
||||||
|
xdgDataHome?: string;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
}): Promise<SupportAssetsResultWithComponent[]> {
|
||||||
|
return (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(options.archive)]]),
|
||||||
|
downloadAsset: async () => options.archive,
|
||||||
|
platform: options.platform ?? 'linux',
|
||||||
|
xdgDataHome: options.xdgDataHome,
|
||||||
|
})) as SupportAssetsResultWithComponent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('detectSupportAssetDataDirs only returns Linux support-asset locations', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
detectSupportAssetDataDirs({
|
detectSupportAssetDataDirs({
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
@@ -46,9 +93,10 @@ test('detectSupportAssetDataDirs only returns Linux rofi theme locations', () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildProtectedSupportAssetsCommand cleans up temporary extraction directory', () => {
|
test('buildProtectedSupportAssetsCommand installs both theme and plugin assets', () => {
|
||||||
const command = buildProtectedSupportAssetsCommand(
|
const command = buildProtectedSupportAssetsCommand(
|
||||||
"https://example.test/subminer assets.tar.gz?sig='abc'",
|
"https://example.test/subminer assets.tar.gz?sig='abc'",
|
||||||
|
'ABCDEF1234',
|
||||||
"/usr/local/share/SubMiner's data",
|
"/usr/local/share/SubMiner's data",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -58,46 +106,419 @@ test('buildProtectedSupportAssetsCommand cleans up temporary extraction director
|
|||||||
command,
|
command,
|
||||||
/curl -fSL 'https:\/\/example\.test\/subminer assets\.tar\.gz\?sig='\\''abc'\\''' -o "\$tmp\/subminer-assets\.tar\.gz"/,
|
/curl -fSL 'https:\/\/example\.test\/subminer assets\.tar\.gz\?sig='\\''abc'\\''' -o "\$tmp\/subminer-assets\.tar\.gz"/,
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
command,
|
||||||
|
/printf '%s %s\\n' 'abcdef1234' "\$tmp\/subminer-assets\.tar\.gz" \| sha256sum -c -/,
|
||||||
|
);
|
||||||
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/themes/);
|
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/themes/);
|
||||||
|
assert.match(
|
||||||
|
command,
|
||||||
|
/sudo cp "\$tmp\/assets\/themes\/subminer\.rasi" '\/usr\/local\/share\/SubMiner'\\''s data'\/themes\/subminer\.rasi/,
|
||||||
|
);
|
||||||
|
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/plugin/);
|
||||||
|
assert.match(command, /sudo rm -rf .*plugin\/subminer\.next/);
|
||||||
|
assert.match(command, /sudo cp -R "\$tmp\/plugin\/subminer" .*plugin\/subminer\.next/);
|
||||||
|
assert.match(command, /sudo mv .*plugin\/subminer\.next.*plugin\/subminer/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateSupportAssetsFromRelease updates only the Linux rofi theme', async () => {
|
test('updateSupportAssetsFromRelease skips on non-Linux platforms', async () => {
|
||||||
|
const { archive, tempDir } = makeSupportAssetsArchive();
|
||||||
|
try {
|
||||||
|
const results = await runLinuxSupportAssetUpdate({
|
||||||
|
archive,
|
||||||
|
platform: 'darwin',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(results, [
|
||||||
|
{
|
||||||
|
status: 'skipped',
|
||||||
|
message: 'Support assets are only installed on Linux.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSupportAssetsFromRelease skips when no managed support-asset roots exist', async () => {
|
||||||
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||||
const dataDir = path.posix.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();
|
const { archive, tempDir } = makeSupportAssetsArchive();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await updateSupportAssetsFromRelease({
|
const results = await runLinuxSupportAssetUpdate({
|
||||||
release: {
|
archive,
|
||||||
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,
|
xdgDataHome,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(results, [{ status: 'updated', path: dataDir }]);
|
assert.deepEqual(results, [
|
||||||
|
{
|
||||||
|
status: 'skipped',
|
||||||
|
message: 'No managed SubMiner support-asset install detected.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSupportAssetsFromRelease skips existing data roots without managed asset markers', async () => {
|
||||||
|
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||||
|
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dataDir, 'preferences.json'), '{}\n');
|
||||||
|
const { archive, tempDir } = makeSupportAssetsArchive();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await runLinuxSupportAssetUpdate({
|
||||||
|
archive,
|
||||||
|
xdgDataHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(results, [
|
||||||
|
{
|
||||||
|
status: 'skipped',
|
||||||
|
message: 'No managed SubMiner support-asset install detected.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSupportAssetsFromRelease installs missing plugin into a root with a managed theme marker', async () => {
|
||||||
|
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||||
|
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||||
|
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
|
||||||
|
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||||
|
extraPluginFiles: [{ relativePath: 'nested.lua', content: 'nested file\n' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await runLinuxSupportAssetUpdate({
|
||||||
|
archive,
|
||||||
|
xdgDataHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(results, [
|
||||||
|
{
|
||||||
|
status: 'updated',
|
||||||
|
component: 'theme',
|
||||||
|
path: dataDir,
|
||||||
|
message: 'Updated theme.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 'updated',
|
||||||
|
component: 'plugin',
|
||||||
|
path: dataDir,
|
||||||
|
message: 'Installed plugin.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
|
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
|
||||||
'new theme\n',
|
'new theme\n',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
|
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
|
||||||
'old plugin\n',
|
'new plugin\n',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(dataDir, 'plugin/subminer/version.lua'), 'utf8'),
|
||||||
|
'return {\n\tversion = "0.12.0",\n}\n',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(dataDir, 'plugin/subminer/nested.lua'), 'utf8'),
|
||||||
|
'nested file\n',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('updateSupportAssetsFromRelease preserves existing plugin when staged replacement copy fails', async () => {
|
||||||
|
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||||
|
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||||
|
const pluginDir = path.join(dataDir, 'plugin/subminer');
|
||||||
|
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||||
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'same theme\n');
|
||||||
|
fs.writeFileSync(path.join(pluginDir, 'main.lua'), 'old plugin\n');
|
||||||
|
fs.writeFileSync(path.join(pluginDir, 'version.lua'), 'return {\n\tversion = "0.11.0",\n}\n');
|
||||||
|
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||||
|
themeContent: 'same theme\n',
|
||||||
|
pluginVersion: '0.12.0',
|
||||||
|
extraPluginFiles: [{ relativePath: 'blocked.lua', content: 'blocked\n' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const originalCp = fs.promises.cp;
|
||||||
|
fs.promises.cp = async (...args: Parameters<typeof fs.promises.cp>) => {
|
||||||
|
const targetPath = String(args[1]);
|
||||||
|
if (
|
||||||
|
targetPath.endsWith(`${path.sep}subminer`) ||
|
||||||
|
targetPath.endsWith(`${path.sep}subminer.next`)
|
||||||
|
) {
|
||||||
|
throw new Error('copy failed');
|
||||||
|
}
|
||||||
|
return originalCp(...args);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runLinuxSupportAssetUpdate({
|
||||||
|
archive,
|
||||||
|
xdgDataHome,
|
||||||
|
}),
|
||||||
|
/copy failed/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.promises.cp = originalCp;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(fs.readFileSync(path.join(pluginDir, 'main.lua'), 'utf8'), 'old plugin\n');
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(pluginDir, 'version.lua'), 'utf8'),
|
||||||
|
'return {\n\tversion = "0.11.0",\n}\n',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSupportAssetsFromRelease reports both activation and rollback failures', async () => {
|
||||||
|
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||||
|
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||||
|
const pluginDir = path.join(dataDir, 'plugin/subminer');
|
||||||
|
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||||
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'same theme\n');
|
||||||
|
fs.writeFileSync(path.join(pluginDir, 'main.lua'), 'old plugin\n');
|
||||||
|
fs.writeFileSync(path.join(pluginDir, 'version.lua'), 'return {\n\tversion = "0.11.0",\n}\n');
|
||||||
|
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||||
|
themeContent: 'same theme\n',
|
||||||
|
pluginVersion: '0.12.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const originalRename = fs.promises.rename;
|
||||||
|
fs.promises.rename = async (...args: Parameters<typeof fs.promises.rename>) => {
|
||||||
|
const sourcePath = String(args[0]);
|
||||||
|
const targetPath = String(args[1]);
|
||||||
|
if (sourcePath.endsWith(`${path.sep}subminer.next`)) {
|
||||||
|
throw new Error('activate failed');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
sourcePath.endsWith(`${path.sep}subminer.bak`) &&
|
||||||
|
targetPath.endsWith(`${path.sep}subminer`)
|
||||||
|
) {
|
||||||
|
throw new Error('rollback failed');
|
||||||
|
}
|
||||||
|
return originalRename(...args);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runLinuxSupportAssetUpdate({
|
||||||
|
archive,
|
||||||
|
xdgDataHome,
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error instanceof AggregateError &&
|
||||||
|
/failed to activate staged plugin/i.test(error.message) &&
|
||||||
|
error.errors.some((nested) => String(nested).includes('activate failed')) &&
|
||||||
|
error.errors.some((nested) => String(nested).includes('rollback failed')),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.promises.rename = originalRename;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSupportAssetsFromRelease skips identical theme and up-to-date plugin', async () => {
|
||||||
|
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||||
|
const dataDir = path.posix.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'), 'same theme\n');
|
||||||
|
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'same plugin\n');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dataDir, 'plugin/subminer/version.lua'),
|
||||||
|
'return {\n\tversion = "0.12.0",\n}\n',
|
||||||
|
);
|
||||||
|
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||||
|
themeContent: 'same theme\n',
|
||||||
|
pluginVersion: '0.12.0',
|
||||||
|
pluginMainContent: 'release plugin differs but version matches\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await runLinuxSupportAssetUpdate({
|
||||||
|
archive,
|
||||||
|
xdgDataHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(results, [
|
||||||
|
{
|
||||||
|
status: 'skipped',
|
||||||
|
component: 'theme',
|
||||||
|
path: dataDir,
|
||||||
|
message: 'Theme already up to date.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 'skipped',
|
||||||
|
component: 'plugin',
|
||||||
|
path: dataDir,
|
||||||
|
message: 'Plugin already up to date.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
|
||||||
|
'same theme\n',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
|
||||||
|
'same plugin\n',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSupportAssetsFromRelease updates changed theme and outdated plugin while removing stale plugin files', async () => {
|
||||||
|
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||||
|
const dataDir = path.posix.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');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dataDir, 'plugin/subminer/version.lua'),
|
||||||
|
'return {\n\tversion = "0.11.0",\n}\n',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/stale.lua'), 'stale\n');
|
||||||
|
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||||
|
themeContent: 'new theme\n',
|
||||||
|
pluginVersion: '0.12.0',
|
||||||
|
pluginMainContent: 'new plugin main\n',
|
||||||
|
extraPluginFiles: [{ relativePath: 'fresh.lua', content: 'fresh\n' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await runLinuxSupportAssetUpdate({
|
||||||
|
archive,
|
||||||
|
xdgDataHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(results, [
|
||||||
|
{
|
||||||
|
status: 'updated',
|
||||||
|
component: 'theme',
|
||||||
|
path: dataDir,
|
||||||
|
message: 'Updated theme.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 'updated',
|
||||||
|
component: 'plugin',
|
||||||
|
path: dataDir,
|
||||||
|
message: 'Updated plugin.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
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'),
|
||||||
|
'new plugin main\n',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(dataDir, 'plugin/subminer/fresh.lua'), 'utf8'),
|
||||||
|
'fresh\n',
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(path.join(dataDir, 'plugin/subminer/stale.lua')), false);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSupportAssetsFromRelease returns protected commands for managed roots that are not writable', async () => {
|
||||||
|
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||||
|
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||||
|
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'managed theme\n');
|
||||||
|
const originalMode = fs.statSync(dataDir).mode & 0o777;
|
||||||
|
fs.chmodSync(dataDir, 0o555);
|
||||||
|
const { archive, tempDir } = makeSupportAssetsArchive();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await runLinuxSupportAssetUpdate({
|
||||||
|
archive,
|
||||||
|
xdgDataHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
results.map((result) => ({
|
||||||
|
status: result.status,
|
||||||
|
component: result.component,
|
||||||
|
path: result.path,
|
||||||
|
command: typeof result.command === 'string',
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
status: 'protected',
|
||||||
|
component: 'theme',
|
||||||
|
path: dataDir,
|
||||||
|
command: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 'protected',
|
||||||
|
component: 'plugin',
|
||||||
|
path: dataDir,
|
||||||
|
command: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.match(results[0]?.command ?? '', /themes\/subminer\.rasi/);
|
||||||
|
assert.match(results[0]?.command ?? '', /plugin\/subminer/);
|
||||||
|
} finally {
|
||||||
|
fs.chmodSync(dataDir, originalMode);
|
||||||
|
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateSupportAssetsFromRelease returns missing-asset when release plugin version metadata is absent', async () => {
|
||||||
|
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
|
||||||
|
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||||
|
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'managed theme\n');
|
||||||
|
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||||
|
pluginVersion: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await runLinuxSupportAssetUpdate({
|
||||||
|
archive,
|
||||||
|
xdgDataHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(results, [
|
||||||
|
{
|
||||||
|
status: 'missing-asset',
|
||||||
|
message: 'Support asset archive has no readable plugin version metadata.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import type { GitHubRelease } from './release-assets';
|
import type { GitHubRelease } from './release-assets';
|
||||||
import { findReleaseAsset } from './release-assets';
|
import { compareSemverLike, findReleaseAsset } from './release-assets';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
const THEME_RELATIVE_PATH = path.join('themes', 'subminer.rasi');
|
||||||
|
const PLUGIN_ENTRYPOINT_RELATIVE_PATH = path.join('plugin', 'subminer', 'main.lua');
|
||||||
|
const PLUGIN_VERSION_RELATIVE_PATH = path.join('plugin', 'subminer', 'version.lua');
|
||||||
|
const PLUGIN_DIR_RELATIVE_PATH = path.join('plugin', 'subminer');
|
||||||
|
|
||||||
export interface SupportAssetsUpdateResult {
|
export interface SupportAssetsUpdateResult {
|
||||||
status: 'updated' | 'skipped' | 'protected' | 'hash-mismatch' | 'missing-asset';
|
status: 'updated' | 'skipped' | 'protected' | 'hash-mismatch' | 'missing-asset';
|
||||||
|
component?: 'theme' | 'plugin';
|
||||||
path?: string;
|
path?: string;
|
||||||
command?: string;
|
command?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -24,6 +29,108 @@ function shellQuote(value: string): string {
|
|||||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePluginVersion(content: string): string | null {
|
||||||
|
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(targetPath: string): Promise<boolean> {
|
||||||
|
return await fs.promises
|
||||||
|
.access(targetPath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canWrite(targetPath: string): Promise<boolean> {
|
||||||
|
return await fs.promises
|
||||||
|
.access(targetPath, fs.constants.W_OK)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFileIfExists(targetPath: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
return await fs.promises.readFile(targetPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readInstalledPluginVersion(pluginDir: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return parsePluginVersion(
|
||||||
|
await fs.promises.readFile(path.join(pluginDir, 'version.lua'), 'utf8'),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectManagedSupportAssetDataDirs(dataDirs: string[]): Promise<string[]> {
|
||||||
|
const managedDataDirs: string[] = [];
|
||||||
|
for (const dataDir of dataDirs) {
|
||||||
|
const [hasTheme, hasPlugin] = await Promise.all([
|
||||||
|
pathExists(path.join(dataDir, THEME_RELATIVE_PATH)),
|
||||||
|
pathExists(path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH)),
|
||||||
|
]);
|
||||||
|
if (hasTheme || hasPlugin) {
|
||||||
|
managedDataDirs.push(dataDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return managedDataDirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replacePluginDir(sourcePluginDir: string, targetPluginDir: string): Promise<void> {
|
||||||
|
const parentDir = path.dirname(targetPluginDir);
|
||||||
|
const stagedDir = `${targetPluginDir}.next`;
|
||||||
|
const backupDir = `${targetPluginDir}.bak`;
|
||||||
|
const targetExists = await pathExists(targetPluginDir);
|
||||||
|
|
||||||
|
await fs.promises.rm(stagedDir, { recursive: true, force: true });
|
||||||
|
await fs.promises.rm(backupDir, { recursive: true, force: true });
|
||||||
|
await fs.promises.mkdir(parentDir, { recursive: true });
|
||||||
|
await fs.promises.cp(sourcePluginDir, stagedDir, { recursive: true });
|
||||||
|
|
||||||
|
if (targetExists) {
|
||||||
|
await fs.promises.rename(targetPluginDir, backupDir);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(stagedDir, targetPluginDir);
|
||||||
|
} catch (err) {
|
||||||
|
if (targetExists) {
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(backupDir, targetPluginDir);
|
||||||
|
} catch (rollbackErr) {
|
||||||
|
throw new AggregateError(
|
||||||
|
[err, rollbackErr],
|
||||||
|
'Failed to activate staged plugin and failed to restore previous plugin directory.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await fs.promises.rm(backupDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSupportAssetResult(
|
||||||
|
status: SupportAssetsUpdateResult['status'],
|
||||||
|
component: SupportAssetsUpdateResult['component'],
|
||||||
|
dataDir: string,
|
||||||
|
message: string,
|
||||||
|
command?: string,
|
||||||
|
): SupportAssetsUpdateResult {
|
||||||
|
const result: SupportAssetsUpdateResult = {
|
||||||
|
status,
|
||||||
|
component,
|
||||||
|
path: dataDir,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
if (command) {
|
||||||
|
result.command = command;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function detectSupportAssetDataDirs(options: {
|
export function detectSupportAssetDataDirs(options: {
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
homeDir: string;
|
homeDir: string;
|
||||||
@@ -40,32 +147,33 @@ export function detectSupportAssetDataDirs(options: {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildProtectedSupportAssetsCommand(assetUrl: string, dataDir: string): string {
|
export function buildProtectedSupportAssetsCommand(
|
||||||
|
assetUrl: string,
|
||||||
|
expectedSha256: string,
|
||||||
|
dataDir: string,
|
||||||
|
): string {
|
||||||
const quotedDir = shellQuote(dataDir);
|
const quotedDir = shellQuote(dataDir);
|
||||||
|
const quotedPluginDir = shellQuote(path.posix.join(dataDir, 'plugin/subminer'));
|
||||||
|
const quotedStagedPluginDir = shellQuote(path.posix.join(dataDir, 'plugin/subminer.next'));
|
||||||
|
const quotedBackupPluginDir = shellQuote(path.posix.join(dataDir, 'plugin/subminer.bak'));
|
||||||
|
const quotedExpectedSha256 = shellQuote(expectedSha256.toLowerCase());
|
||||||
return [
|
return [
|
||||||
'tmp=$(mktemp -d)',
|
'tmp=$(mktemp -d)',
|
||||||
'trap \'rm -rf "$tmp"\' EXIT',
|
'trap \'rm -rf "$tmp"\' EXIT',
|
||||||
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
|
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
|
||||||
|
`printf '%s %s\\n' ${quotedExpectedSha256} "$tmp/subminer-assets.tar.gz" | sha256sum -c -`,
|
||||||
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
|
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
|
||||||
`sudo mkdir -p ${quotedDir}/themes`,
|
`sudo mkdir -p ${quotedDir}/themes`,
|
||||||
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
|
`sudo cp "$tmp/assets/themes/subminer.rasi" ${quotedDir}/themes/subminer.rasi`,
|
||||||
|
`sudo mkdir -p ${quotedDir}/plugin`,
|
||||||
|
`sudo rm -rf ${quotedStagedPluginDir} ${quotedBackupPluginDir}`,
|
||||||
|
`sudo cp -R "$tmp/plugin/subminer" ${quotedStagedPluginDir}`,
|
||||||
|
`[ ! -e ${quotedPluginDir} ] || sudo mv ${quotedPluginDir} ${quotedBackupPluginDir}`,
|
||||||
|
`sudo mv ${quotedStagedPluginDir} ${quotedPluginDir}`,
|
||||||
|
`sudo rm -rf ${quotedBackupPluginDir}`,
|
||||||
].join(' && ');
|
].join(' && ');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pathExists(targetPath: string): Promise<boolean> {
|
|
||||||
return await fs.promises
|
|
||||||
.access(targetPath)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function canWrite(targetPath: string): Promise<boolean> {
|
|
||||||
return await fs.promises
|
|
||||||
.access(targetPath, fs.constants.W_OK)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSupportAssetsFromRelease(options: {
|
export async function updateSupportAssetsFromRelease(options: {
|
||||||
release: GitHubRelease | null;
|
release: GitHubRelease | null;
|
||||||
sha256Sums: Map<string, string>;
|
sha256Sums: Map<string, string>;
|
||||||
@@ -79,10 +187,12 @@ export async function updateSupportAssetsFromRelease(options: {
|
|||||||
}
|
}
|
||||||
if (!options.release) return [{ status: 'missing-asset', message: 'No release found.' }];
|
if (!options.release) return [{ status: 'missing-asset', message: 'No release found.' }];
|
||||||
const asset = findReleaseAsset(options.release, 'subminer-assets.tar.gz');
|
const asset = findReleaseAsset(options.release, 'subminer-assets.tar.gz');
|
||||||
if (!asset) return [{ status: 'missing-asset', message: 'Release has no rofi theme asset.' }];
|
if (!asset) {
|
||||||
|
return [{ status: 'missing-asset', message: 'Release has no support asset archive.' }];
|
||||||
|
}
|
||||||
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
|
const expectedSha256 = options.sha256Sums.get('subminer-assets.tar.gz');
|
||||||
if (!expectedSha256) {
|
if (!expectedSha256) {
|
||||||
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no rofi theme entry.' }];
|
return [{ status: 'missing-asset', message: 'SHA256SUMS.txt has no support asset entry.' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataDirs = detectSupportAssetDataDirs({
|
const dataDirs = detectSupportAssetDataDirs({
|
||||||
@@ -90,41 +200,69 @@ export async function updateSupportAssetsFromRelease(options: {
|
|||||||
homeDir: options.homeDir ?? os.homedir(),
|
homeDir: options.homeDir ?? os.homedir(),
|
||||||
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
|
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
|
||||||
});
|
});
|
||||||
const existingDataDirs: string[] = [];
|
const managedDataDirs = await detectManagedSupportAssetDataDirs(dataDirs);
|
||||||
for (const dataDir of dataDirs) {
|
if (managedDataDirs.length === 0) {
|
||||||
const hasTheme = await pathExists(path.join(dataDir, 'themes/subminer.rasi'));
|
return [{ status: 'skipped', message: 'No managed SubMiner support-asset install detected.' }];
|
||||||
if (hasTheme) existingDataDirs.push(dataDir);
|
|
||||||
}
|
|
||||||
if (existingDataDirs.length === 0) {
|
|
||||||
return [{ status: 'skipped', message: 'No existing rofi theme install detected.' }];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const protectedResults: SupportAssetsUpdateResult[] = existingDataDirs
|
const results: SupportAssetsUpdateResult[] = [];
|
||||||
.filter((dataDir) => !fs.existsSync(dataDir) || !fs.statSync(dataDir).isDirectory())
|
|
||||||
.map((dataDir) => ({
|
|
||||||
status: 'skipped' as const,
|
|
||||||
path: dataDir,
|
|
||||||
message: 'Support asset path is not a directory.',
|
|
||||||
}));
|
|
||||||
const writableDataDirs: string[] = [];
|
const writableDataDirs: string[] = [];
|
||||||
for (const dataDir of existingDataDirs) {
|
for (const dataDir of managedDataDirs) {
|
||||||
|
if (!fs.existsSync(dataDir) || !fs.statSync(dataDir).isDirectory()) {
|
||||||
|
results.push(
|
||||||
|
makeSupportAssetResult(
|
||||||
|
'skipped',
|
||||||
|
'theme',
|
||||||
|
dataDir,
|
||||||
|
'Support asset path is not a directory.',
|
||||||
|
),
|
||||||
|
makeSupportAssetResult(
|
||||||
|
'skipped',
|
||||||
|
'plugin',
|
||||||
|
dataDir,
|
||||||
|
'Support asset path is not a directory.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (await canWrite(dataDir)) {
|
if (await canWrite(dataDir)) {
|
||||||
writableDataDirs.push(dataDir);
|
writableDataDirs.push(dataDir);
|
||||||
} else {
|
continue;
|
||||||
protectedResults.push({
|
|
||||||
status: 'protected',
|
|
||||||
path: dataDir,
|
|
||||||
command: buildProtectedSupportAssetsCommand(asset.browser_download_url, dataDir),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const command = buildProtectedSupportAssetsCommand(
|
||||||
|
asset.browser_download_url,
|
||||||
|
expectedSha256,
|
||||||
|
dataDir,
|
||||||
|
);
|
||||||
|
results.push(
|
||||||
|
makeSupportAssetResult(
|
||||||
|
'protected',
|
||||||
|
'theme',
|
||||||
|
dataDir,
|
||||||
|
'Theme install requires a manual command.',
|
||||||
|
command,
|
||||||
|
),
|
||||||
|
makeSupportAssetResult(
|
||||||
|
'protected',
|
||||||
|
'plugin',
|
||||||
|
dataDir,
|
||||||
|
'Plugin install requires a manual command.',
|
||||||
|
command,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (writableDataDirs.length === 0) {
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
if (writableDataDirs.length === 0) return protectedResults;
|
|
||||||
|
|
||||||
const archive = await options.downloadAsset(asset.browser_download_url);
|
const archive = await options.downloadAsset(asset.browser_download_url);
|
||||||
const actualSha256 = sha256(archive);
|
const actualSha256 = sha256(archive);
|
||||||
if (actualSha256 !== expectedSha256.toLowerCase()) {
|
if (actualSha256 !== expectedSha256.toLowerCase()) {
|
||||||
return [
|
return [
|
||||||
...protectedResults,
|
...results,
|
||||||
{
|
{
|
||||||
status: 'hash-mismatch',
|
status: 'hash-mismatch',
|
||||||
message: `Expected ${expectedSha256}, got ${actualSha256}.`,
|
message: `Expected ${expectedSha256}, got ${actualSha256}.`,
|
||||||
@@ -137,17 +275,85 @@ export async function updateSupportAssetsFromRelease(options: {
|
|||||||
const archivePath = path.join(tempDir, 'subminer-assets.tar.gz');
|
const archivePath = path.join(tempDir, 'subminer-assets.tar.gz');
|
||||||
await fs.promises.writeFile(archivePath, archive);
|
await fs.promises.writeFile(archivePath, archive);
|
||||||
await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir]);
|
await execFileAsync('tar', ['-xzf', archivePath, '-C', tempDir]);
|
||||||
const results: SupportAssetsUpdateResult[] = [...protectedResults];
|
|
||||||
|
const themeSourcePath = path.join(tempDir, 'assets/themes/subminer.rasi');
|
||||||
|
if (!(await pathExists(themeSourcePath))) {
|
||||||
|
return [
|
||||||
|
{ status: 'missing-asset', message: 'Support asset archive is missing the rofi theme.' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const themeBytes = await fs.promises.readFile(themeSourcePath);
|
||||||
|
|
||||||
|
const sourcePluginDir = path.join(tempDir, PLUGIN_DIR_RELATIVE_PATH);
|
||||||
|
const sourcePluginEntrypoint = path.join(tempDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH);
|
||||||
|
if (!(await pathExists(sourcePluginEntrypoint))) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
status: 'missing-asset',
|
||||||
|
message: 'Support asset archive is missing the runtime plugin.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const sourcePluginVersion = parsePluginVersion(
|
||||||
|
await fs.promises
|
||||||
|
.readFile(path.join(tempDir, PLUGIN_VERSION_RELATIVE_PATH), 'utf8')
|
||||||
|
.catch(() => ''),
|
||||||
|
);
|
||||||
|
if (!sourcePluginVersion) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
status: 'missing-asset',
|
||||||
|
message: 'Support asset archive has no readable plugin version metadata.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
for (const dataDir of writableDataDirs) {
|
for (const dataDir of writableDataDirs) {
|
||||||
const targetThemePath = path.join(dataDir, 'themes/subminer.rasi');
|
const targetThemePath = path.join(dataDir, THEME_RELATIVE_PATH);
|
||||||
if (await pathExists(targetThemePath)) {
|
const existingThemeBytes = await readFileIfExists(targetThemePath);
|
||||||
await fs.promises.copyFile(
|
if (existingThemeBytes && Buffer.compare(existingThemeBytes, themeBytes) === 0) {
|
||||||
path.join(tempDir, 'assets/themes/subminer.rasi'),
|
results.push(
|
||||||
targetThemePath,
|
makeSupportAssetResult('skipped', 'theme', dataDir, 'Theme already up to date.'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await fs.promises.mkdir(path.dirname(targetThemePath), { recursive: true });
|
||||||
|
await fs.promises.writeFile(targetThemePath, themeBytes);
|
||||||
|
results.push(
|
||||||
|
makeSupportAssetResult(
|
||||||
|
'updated',
|
||||||
|
'theme',
|
||||||
|
dataDir,
|
||||||
|
existingThemeBytes ? 'Updated theme.' : 'Installed theme.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
results.push({ status: 'updated', path: dataDir });
|
|
||||||
|
const targetPluginDir = path.join(dataDir, PLUGIN_DIR_RELATIVE_PATH);
|
||||||
|
const targetPluginEntrypoint = path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH);
|
||||||
|
const installedPluginVersion = await readInstalledPluginVersion(targetPluginDir);
|
||||||
|
const installedEntrypointExists = await pathExists(targetPluginEntrypoint);
|
||||||
|
const shouldInstallPlugin =
|
||||||
|
!installedEntrypointExists ||
|
||||||
|
!installedPluginVersion ||
|
||||||
|
compareSemverLike(sourcePluginVersion, installedPluginVersion) > 0;
|
||||||
|
if (!shouldInstallPlugin) {
|
||||||
|
results.push(
|
||||||
|
makeSupportAssetResult('skipped', 'plugin', dataDir, 'Plugin already up to date.'),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await replacePluginDir(sourcePluginDir, targetPluginDir);
|
||||||
|
results.push(
|
||||||
|
makeSupportAssetResult(
|
||||||
|
'updated',
|
||||||
|
'plugin',
|
||||||
|
dataDir,
|
||||||
|
installedEntrypointExists ? 'Updated plugin.' : 'Installed plugin.',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
} finally {
|
} finally {
|
||||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
|
||||||
|
|
||||||
|
test('runSupportAssetUpdatesForLauncherResult logs support-asset errors and preserves launcher result', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const launcherResult = { status: 'updated' } as const;
|
||||||
|
const result = await runSupportAssetUpdatesForLauncherResult({
|
||||||
|
launcherResult,
|
||||||
|
updateSupportAssets: async () => {
|
||||||
|
throw new Error('archive failed');
|
||||||
|
},
|
||||||
|
logWarn: (message, details) => {
|
||||||
|
warnings.push(`${message}:${details instanceof Error ? details.message : String(details)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, launcherResult);
|
||||||
|
assert.deepEqual(warnings, ['Support asset update failed after launcher update:archive failed']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runSupportAssetUpdatesForLauncherResult uses support asset description in skip warnings', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const launcherResult = { status: 'updated' } as const;
|
||||||
|
|
||||||
|
const result = await runSupportAssetUpdatesForLauncherResult({
|
||||||
|
launcherResult,
|
||||||
|
assetDescription: 'Support asset update',
|
||||||
|
updateSupportAssets: async () => [
|
||||||
|
{ status: 'protected', command: 'install-theme' },
|
||||||
|
{ status: 'hash-mismatch', message: 'checksum failed' },
|
||||||
|
],
|
||||||
|
logWarn: (message) => {
|
||||||
|
warnings.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, launcherResult);
|
||||||
|
assert.deepEqual(warnings, [
|
||||||
|
'Support asset update requires manual command: install-theme',
|
||||||
|
'Support asset update skipped: checksum failed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { app, dialog } from 'electron';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { UpdateChannel, UpdatesConfig } from '../../../types/config';
|
||||||
|
import type { OverlayNotificationPayload } from '../../../types/notification';
|
||||||
|
import { createElectronAppUpdater, isNativeUpdaterSupported } from './app-updater';
|
||||||
|
import { createCurlFetch, createGlobalFetch } from './fetch-adapter';
|
||||||
|
import { createCurlHttpExecutor } from './curl-http-executor';
|
||||||
|
import { createFetchHttpExecutor } from './fetch-http-executor';
|
||||||
|
import {
|
||||||
|
fetchLatestStableRelease,
|
||||||
|
fetchReleaseAssetBuffer,
|
||||||
|
fetchReleaseAssetText,
|
||||||
|
findReleaseAsset,
|
||||||
|
parseSha256Sums,
|
||||||
|
type GitHubRelease,
|
||||||
|
} from './release-assets';
|
||||||
|
import { shouldFetchReleaseMetadataForPlatform } from './release-metadata-policy';
|
||||||
|
import { updateLauncherFromRelease } from './launcher-updater';
|
||||||
|
import { notifyUpdateAvailable } from './update-notifications';
|
||||||
|
import { createUpdateDialogPresenter } from './update-dialogs';
|
||||||
|
import { createFileUpdateStateStore, createUpdateService } from './update-service';
|
||||||
|
import { updateSupportAssetsFromRelease } from './support-assets';
|
||||||
|
import { runSupportAssetUpdatesForLauncherResult } from './update-support-assets-runtime';
|
||||||
|
|
||||||
|
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
|
||||||
|
|
||||||
|
export interface UpdateServiceRuntimeDeps {
|
||||||
|
userDataPath: string;
|
||||||
|
getUpdatesConfig: () => Required<UpdatesConfig>;
|
||||||
|
logInfo: (message: string) => void;
|
||||||
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
|
||||||
|
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||||
|
showMpvOsd: (message: string) => void;
|
||||||
|
withStatsWindowLayerSuspendedForNativeDialog: <T>(showDialog: () => Promise<T>) => Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUpdateServiceRuntime(deps: UpdateServiceRuntimeDeps): {
|
||||||
|
getUpdateService: () => ReturnType<typeof createUpdateService>;
|
||||||
|
} {
|
||||||
|
const updateStateStore = createFileUpdateStateStore(
|
||||||
|
path.join(deps.userDataPath, 'update-state.json'),
|
||||||
|
);
|
||||||
|
let updateService: ReturnType<typeof createUpdateService> | null = null;
|
||||||
|
const globalFetchForUpdater = createGlobalFetch();
|
||||||
|
const curlFetch = createCurlFetch();
|
||||||
|
|
||||||
|
function createNativeUpdaterHttpExecutor() {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return createFetchHttpExecutor();
|
||||||
|
}
|
||||||
|
return createCurlHttpExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFetchForUpdater() {
|
||||||
|
if (process.platform === 'win32') return globalFetchForUpdater;
|
||||||
|
return curlFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLauncherFromSelectedRelease(
|
||||||
|
launcherPath?: string,
|
||||||
|
channel: UpdateChannel = deps.getUpdatesConfig().channel,
|
||||||
|
release: GitHubRelease | null = null,
|
||||||
|
) {
|
||||||
|
const fetchForUpdater = getFetchForUpdater();
|
||||||
|
if (!release) {
|
||||||
|
return { status: 'missing-asset', message: `No ${channel} GitHub release found.` };
|
||||||
|
}
|
||||||
|
const sumsAsset = findReleaseAsset(release, 'SHA256SUMS.txt');
|
||||||
|
if (!sumsAsset) {
|
||||||
|
return { status: 'missing-asset', message: 'Release has no SHA256SUMS.txt asset.' };
|
||||||
|
}
|
||||||
|
const sums = parseSha256Sums(
|
||||||
|
await fetchReleaseAssetText(fetchForUpdater, sumsAsset.browser_download_url),
|
||||||
|
);
|
||||||
|
const launcherResult = await updateLauncherFromRelease({
|
||||||
|
release,
|
||||||
|
sha256Sums: sums,
|
||||||
|
launcherPath,
|
||||||
|
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
|
||||||
|
});
|
||||||
|
return runSupportAssetUpdatesForLauncherResult({
|
||||||
|
launcherResult,
|
||||||
|
assetDescription: 'Support asset update',
|
||||||
|
updateSupportAssets: () =>
|
||||||
|
updateSupportAssetsFromRelease({
|
||||||
|
release,
|
||||||
|
sha256Sums: sums,
|
||||||
|
downloadAsset: (url) => fetchReleaseAssetBuffer(fetchForUpdater, url),
|
||||||
|
}),
|
||||||
|
logWarn: (message, details) => deps.logWarn(message, details),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpdateService() {
|
||||||
|
if (updateService) return updateService;
|
||||||
|
const appUpdater = createElectronAppUpdater({
|
||||||
|
currentVersion: app.getVersion(),
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
log: (message) => deps.logInfo(message),
|
||||||
|
getChannel: () => deps.getUpdatesConfig().channel,
|
||||||
|
configureHttpExecutor: createNativeUpdaterHttpExecutor,
|
||||||
|
disableDifferentialDownload: true,
|
||||||
|
isNativeUpdaterSupported: () =>
|
||||||
|
isNativeUpdaterSupported({
|
||||||
|
platform: process.platform,
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
execPath: process.execPath,
|
||||||
|
env: process.env,
|
||||||
|
log: (message) => deps.logWarn(message),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const updateDialogPresenter = createUpdateDialogPresenter({
|
||||||
|
platform: process.platform,
|
||||||
|
focusApp: async () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.focus({ steal: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await app.dock?.show();
|
||||||
|
} catch (error) {
|
||||||
|
deps.logWarn('Failed to show macOS dock before update dialog', error);
|
||||||
|
}
|
||||||
|
// app.focus({ steal: true }) alone does not reliably activate the process
|
||||||
|
// when SubMiner was reached via `subminer -u` (single-instance forwarding
|
||||||
|
// from a CLI-spawned child). osascript's `activate` uses LaunchServices,
|
||||||
|
// which is the only path that reliably brings the running app forward.
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
execFile(
|
||||||
|
'/usr/bin/osascript',
|
||||||
|
['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`],
|
||||||
|
{ timeout: 2000 },
|
||||||
|
(error) => {
|
||||||
|
if (error) {
|
||||||
|
deps.logWarn(
|
||||||
|
`Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
app.focus({ steal: true });
|
||||||
|
},
|
||||||
|
withStatsWindowLayerSuspended: (showDialog) =>
|
||||||
|
deps.withStatsWindowLayerSuspendedForNativeDialog(showDialog),
|
||||||
|
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||||
|
});
|
||||||
|
updateService = createUpdateService({
|
||||||
|
getConfig: () => deps.getUpdatesConfig(),
|
||||||
|
getCurrentVersion: () => app.getVersion(),
|
||||||
|
now: () => Date.now(),
|
||||||
|
readState: () => updateStateStore.readState(),
|
||||||
|
writeState: (state) => updateStateStore.writeState(state),
|
||||||
|
checkAppUpdate: (channel) => appUpdater.checkForUpdates(channel),
|
||||||
|
shouldFetchReleaseMetadata: ({ request, appUpdate }) =>
|
||||||
|
shouldFetchReleaseMetadataForPlatform(process.platform, appUpdate, request),
|
||||||
|
fetchLatestStableRelease: (channel) =>
|
||||||
|
fetchLatestStableRelease({ fetch: getFetchForUpdater(), channel }),
|
||||||
|
updateLauncher: (launcherPath, channel, release) =>
|
||||||
|
updateLauncherFromSelectedRelease(launcherPath, channel, release),
|
||||||
|
showNoUpdateDialog: (version) => updateDialogPresenter.showNoUpdateDialog(version),
|
||||||
|
showUpdateAvailableDialog: (version) =>
|
||||||
|
updateDialogPresenter.showUpdateAvailableDialog(version),
|
||||||
|
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
|
||||||
|
showManualUpdateRequiredDialog: (version) =>
|
||||||
|
updateDialogPresenter.showManualUpdateRequiredDialog(version),
|
||||||
|
downloadAppUpdate: () => appUpdater.downloadUpdate(),
|
||||||
|
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
|
||||||
|
quitAndInstall: () => appUpdater.quitAndInstall(),
|
||||||
|
notifyUpdateAvailable: (version) =>
|
||||||
|
notifyUpdateAvailable(
|
||||||
|
{ notificationType: deps.getUpdatesConfig().notificationType, version },
|
||||||
|
{
|
||||||
|
showSystemNotification: (title, body) => deps.showDesktopNotification(title, { body }),
|
||||||
|
showOverlayNotification: (payload) => deps.showOverlayNotification(payload),
|
||||||
|
showOsdNotification: (message) => {
|
||||||
|
deps.showMpvOsd(message);
|
||||||
|
},
|
||||||
|
log: (message) => deps.logWarn(message),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
log: (message) => deps.logWarn(message),
|
||||||
|
});
|
||||||
|
return updateService;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getUpdateService };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export async function runSupportAssetUpdatesForLauncherResult<
|
||||||
|
TLauncherResult,
|
||||||
|
TSupportResult extends { status: string; command?: string; message?: string },
|
||||||
|
>(options: {
|
||||||
|
launcherResult: TLauncherResult;
|
||||||
|
assetDescription?: string;
|
||||||
|
updateSupportAssets: () => Promise<TSupportResult[]>;
|
||||||
|
logWarn: (message: string, details?: unknown) => void;
|
||||||
|
}): Promise<TLauncherResult> {
|
||||||
|
const assetDescription = options.assetDescription ?? 'Support asset update';
|
||||||
|
try {
|
||||||
|
const supportResults = await options.updateSupportAssets();
|
||||||
|
for (const result of supportResults) {
|
||||||
|
if (result.status === 'protected' && result.command) {
|
||||||
|
options.logWarn(`${assetDescription} requires manual command: ${result.command}`);
|
||||||
|
} else if (result.status === 'hash-mismatch' || result.status === 'missing-asset') {
|
||||||
|
options.logWarn(`${assetDescription} skipped: ${result.message ?? result.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
options.logWarn('Support asset update failed after launcher update', error);
|
||||||
|
}
|
||||||
|
return options.launcherResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,831 @@
|
|||||||
|
import { type BrowserWindow, screen } from 'electron';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
|
||||||
|
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
|
||||||
|
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
|
||||||
|
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
|
||||||
|
import type { BaseWindowTracker } from '../../window-trackers';
|
||||||
|
import {
|
||||||
|
bindWindowsOverlayAboveMpv,
|
||||||
|
clearWindowsOverlayOwner,
|
||||||
|
findWindowsMpvTargetWindowHandle,
|
||||||
|
getWindowsForegroundProcessName,
|
||||||
|
setWindowsOverlayOwner,
|
||||||
|
} from '../../window-trackers/windows-helper';
|
||||||
|
import {
|
||||||
|
applyLinuxOverlayInputShape,
|
||||||
|
applyLinuxOverlayPointerInteractionMousePassthrough,
|
||||||
|
ensureLinuxOverlayPointerInteractionLoop,
|
||||||
|
type ForegroundSuppressionGraceState,
|
||||||
|
mapOverlayMeasurementForPointerInteraction,
|
||||||
|
resolveForegroundSuppressionWithGrace,
|
||||||
|
shouldPrimeLinuxOverlayInteractionFromMeasurement,
|
||||||
|
tickLinuxOverlayPointerInteraction,
|
||||||
|
} from './linux-overlay-pointer-interaction';
|
||||||
|
import { restoreLinuxOverlayWindowShape } from './linux-overlay-window-shape';
|
||||||
|
import {
|
||||||
|
ensureLinuxOverlayZOrderKeepAliveLoop,
|
||||||
|
shouldRunLinuxOverlayZOrderKeepAlive,
|
||||||
|
tickLinuxOverlayZOrderKeepAlive,
|
||||||
|
} from './linux-overlay-zorder-keepalive';
|
||||||
|
import { createLinuxX11CursorPointReader } from './linux-x11-cursor-point';
|
||||||
|
import type { LinuxVisibleOverlayWindowMode } from './linux-visible-overlay-window-mode';
|
||||||
|
import { createStatsOverlayVisibilityChangeHandler } from './stats-overlay-visibility';
|
||||||
|
import { hasLiveSeparateWindow } from './settings-window-z-order';
|
||||||
|
|
||||||
|
export interface VisibleOverlayInteractionRuntimeDeps {
|
||||||
|
overlayManager: {
|
||||||
|
getMainWindow: () => BrowserWindow | null;
|
||||||
|
getVisibleOverlayVisible: () => boolean;
|
||||||
|
};
|
||||||
|
overlayContentMeasurementStore: {
|
||||||
|
clear: (layer: 'visible') => void;
|
||||||
|
getLatestByLayer: (layer: 'visible') => OverlayContentMeasurement | null;
|
||||||
|
};
|
||||||
|
logger: {
|
||||||
|
info: (message: string, ...args: unknown[]) => void;
|
||||||
|
warn: (message: string, ...args: unknown[]) => void;
|
||||||
|
debug: (message: string, ...args: unknown[]) => void;
|
||||||
|
};
|
||||||
|
updateVisibleOverlayVisibility: () => void;
|
||||||
|
getModalInputExclusive: () => boolean;
|
||||||
|
getStatsOverlayVisible: () => boolean;
|
||||||
|
setStatsOverlayVisible: (visible: boolean) => void;
|
||||||
|
getWindowTracker: () => BaseWindowTracker | null;
|
||||||
|
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
|
||||||
|
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||||
|
getMpvSocketPath: () => string;
|
||||||
|
getBackendOverride: () => string | null;
|
||||||
|
getInitialArgs: () => CliArgs | null;
|
||||||
|
getOverlayRuntimeInitialized: () => boolean;
|
||||||
|
getLinuxVisibleOverlayWindowMode: () => LinuxVisibleOverlayWindowMode;
|
||||||
|
setLinuxVisibleOverlayOwnerBindingKey: (key: string | null) => void;
|
||||||
|
bindVisibleOverlayToTrackedX11Window: (window: BrowserWindow) => void;
|
||||||
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
|
refreshCurrentSubtitle: () => void;
|
||||||
|
getOverlayWindows: () => BrowserWindow[];
|
||||||
|
syncOverlayShortcuts: () => void;
|
||||||
|
resetLastOverlayWindowGeometry: () => void;
|
||||||
|
enforceOverlayLayerOrder: () => void;
|
||||||
|
getOverlayForegroundSeparateWindows: () => BrowserWindow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInteractionRuntimeDeps) {
|
||||||
|
const { overlayManager, overlayContentMeasurementStore, logger } = deps;
|
||||||
|
|
||||||
|
const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
||||||
|
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
||||||
|
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||||
|
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
||||||
|
const LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 1_500;
|
||||||
|
// Ignore transient "neither mpv nor overlay is the active window" blips before suppressing
|
||||||
|
// subtitle pointer interaction. Right after playback starts the overlay can briefly become the
|
||||||
|
// X11 active window, which would otherwise leave subtitles inert for a poll cycle (~1s).
|
||||||
|
const LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS = 500;
|
||||||
|
const LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS = 1_500;
|
||||||
|
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
|
||||||
|
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
|
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
|
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||||
|
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||||
|
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||||
|
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||||
|
let lastLinuxVisibleOverlayFollowedMpvAtMs = 0;
|
||||||
|
const linuxPointerForegroundSuppressionGrace: ForegroundSuppressionGraceState = {
|
||||||
|
lossSinceMs: null,
|
||||||
|
};
|
||||||
|
let visibleOverlayInteractionActive = false;
|
||||||
|
let linuxOverlayInputShapeActive = false;
|
||||||
|
let linuxOverlayPointerInteractionStateApplied = process.platform !== 'linux';
|
||||||
|
let linuxVisibleOverlayStartupInputPrimed = false;
|
||||||
|
let linuxVisibleOverlayStartupInputGraceUntilMs = 0;
|
||||||
|
// Renderer-reported interactive hint (Linux only): true while a Yomitan popup/modal
|
||||||
|
// region is interactive, so the cursor poll keeps the overlay interactive even when the cursor
|
||||||
|
// moves off measured subtitle/sidebar rects onto the popup.
|
||||||
|
let linuxOverlayInteractiveHint = false;
|
||||||
|
let macOSVisibleOverlayForegroundProbeActive = false;
|
||||||
|
let macOSVisibleOverlayForegroundProbeToken = 0;
|
||||||
|
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const linuxVisibleOverlayOwnerBindingQueues = new WeakMap<BrowserWindow, Promise<void>>();
|
||||||
|
|
||||||
|
const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({
|
||||||
|
setStatsOverlayVisibleState: (visible) => {
|
||||||
|
deps.setStatsOverlayVisible(visible);
|
||||||
|
},
|
||||||
|
resetVisibleOverlayInteraction: () => {
|
||||||
|
visibleOverlayInteractionActive = false;
|
||||||
|
},
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetVisibleOverlayInputState(): void {
|
||||||
|
visibleOverlayInteractionActive = false;
|
||||||
|
linuxOverlayInputShapeActive = false;
|
||||||
|
linuxOverlayPointerInteractionStateApplied = false;
|
||||||
|
resetLinuxVisibleOverlayStartupInputPrimer();
|
||||||
|
linuxOverlayInteractiveHint = false;
|
||||||
|
overlayContentMeasurementStore.clear('visible');
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (process.platform === 'linux' && mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
restoreLinuxOverlayWindowShape(mainWindow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreVisibleOverlayWindowShapeForShow(): void {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreLinuxOverlayWindowShape(overlayManager.getMainWindow());
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearVisibleOverlayBlurRefreshTimeouts(): void {
|
||||||
|
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
visibleOverlayBlurRefreshTimeouts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
||||||
|
for (const timeout of windowsVisibleOverlayZOrderRetryTimeouts) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
windowsVisibleOverlayZOrderRetryTimeouts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishMacOSVisibleOverlayForegroundProbe(token: number): void {
|
||||||
|
if (token !== macOSVisibleOverlayForegroundProbeToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
|
||||||
|
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
|
||||||
|
macOSVisibleOverlayForegroundProbeTimeout = null;
|
||||||
|
}
|
||||||
|
if (!macOSVisibleOverlayForegroundProbeActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
macOSVisibleOverlayForegroundProbeActive = false;
|
||||||
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMacOSVisibleOverlayForegroundProbe(): void {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tracker = deps.getWindowTracker();
|
||||||
|
if (!tracker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
macOSVisibleOverlayForegroundProbeActive = true;
|
||||||
|
const token = ++macOSVisibleOverlayForegroundProbeToken;
|
||||||
|
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
|
||||||
|
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
|
||||||
|
}
|
||||||
|
macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => {
|
||||||
|
finishMacOSVisibleOverlayForegroundProbe(token);
|
||||||
|
}, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS);
|
||||||
|
|
||||||
|
void tracker
|
||||||
|
.refreshNow()
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn('Failed to refresh macOS frontmost app after overlay blur', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
finishMacOSVisibleOverlayForegroundProbe(token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNativeWindowHandleDecimal(window: BrowserWindow): string {
|
||||||
|
const handle = window.getNativeWindowHandle();
|
||||||
|
return handle.length >= 8
|
||||||
|
? handle.readBigUInt64LE(0).toString()
|
||||||
|
: BigInt(handle.readUInt32LE(0)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
|
||||||
|
return getNativeWindowHandleDecimal(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowsNativeWindowHandleNumber(window: BrowserWindow): number {
|
||||||
|
const handle = window.getNativeWindowHandle();
|
||||||
|
return handle.length >= 8 ? Number(handle.readBigUInt64LE(0)) : handle.readUInt32LE(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueVisibleOverlayX11OwnerBindingOperation(
|
||||||
|
window: BrowserWindow,
|
||||||
|
args: string[],
|
||||||
|
onError?: (error: Error) => void,
|
||||||
|
): void {
|
||||||
|
const previous = linuxVisibleOverlayOwnerBindingQueues.get(window) ?? Promise.resolve();
|
||||||
|
const operation = previous
|
||||||
|
.catch(() => {})
|
||||||
|
.then(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (window.isDestroyed()) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
execFile('xprop', args, { timeout: 1500 }, (error) => {
|
||||||
|
if (error) {
|
||||||
|
onError?.(error);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const queued = operation.finally(() => {
|
||||||
|
if (linuxVisibleOverlayOwnerBindingQueues.get(window) === queued) {
|
||||||
|
linuxVisibleOverlayOwnerBindingQueues.delete(window);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
linuxVisibleOverlayOwnerBindingQueues.set(window, queued);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearVisibleOverlayX11OwnerBinding(window: BrowserWindow): void {
|
||||||
|
if (window.isDestroyed()) return;
|
||||||
|
enqueueVisibleOverlayX11OwnerBindingOperation(window, [
|
||||||
|
'-id',
|
||||||
|
getNativeWindowHandleDecimal(window),
|
||||||
|
'-remove',
|
||||||
|
'WM_TRANSIENT_FOR',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWindowsOverlayBindTargetHandle(
|
||||||
|
targetMpvSocketPath?: string | null,
|
||||||
|
): number | null {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (targetMpvSocketPath) {
|
||||||
|
const windowTracker = deps.getWindowTracker() as {
|
||||||
|
getTargetWindowHandle?: () => number | null;
|
||||||
|
} | null;
|
||||||
|
const trackedHandle = windowTracker?.getTargetWindowHandle?.();
|
||||||
|
if (typeof trackedHandle === 'number' && Number.isFinite(trackedHandle)) {
|
||||||
|
return trackedHandle;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return findWindowsMpvTargetWindowHandle();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOverlayWindowTracker(
|
||||||
|
override?: string | null,
|
||||||
|
targetMpvSocketPath?: string | null,
|
||||||
|
) {
|
||||||
|
const initialArgs = deps.getInitialArgs();
|
||||||
|
if (initialArgs && isHeadlessInitialCommand(initialArgs)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createWindowTrackerCore(override, targetMpvSocketPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindVisibleOverlayOwner(): void {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
deps.bindVisibleOverlayToTrackedX11Window(mainWindow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||||
|
const targetSocketPath = deps.getMpvSocketPath();
|
||||||
|
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(targetSocketPath);
|
||||||
|
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetSocketPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tracker = deps.getWindowTracker();
|
||||||
|
const mpvResult = tracker
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
const win32 =
|
||||||
|
require('../../window-trackers/win32') as typeof import('../../window-trackers/win32');
|
||||||
|
const poll = win32.findMpvWindows();
|
||||||
|
const focused = poll.matches.find((m) => m.isForeground);
|
||||||
|
return focused ?? [...poll.matches].sort((a, b) => b.area - a.area)[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
if (!mpvResult) return;
|
||||||
|
if (!setWindowsOverlayOwner(overlayHwnd, mpvResult.hwnd)) {
|
||||||
|
logger.warn('Failed to set overlay owner via koffi');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseVisibleOverlayOwner(): void {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
deps.setLinuxVisibleOverlayOwnerBindingKey(null);
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
clearVisibleOverlayX11OwnerBinding(mainWindow);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.platform !== 'win32' || !mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||||
|
if (!clearWindowsOverlayOwner(overlayHwnd)) {
|
||||||
|
logger.warn('Failed to clear overlay owner via koffi');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOverlayWindowTrackerForCurrentSocket(): void {
|
||||||
|
startOverlayWindowTrackerCore({
|
||||||
|
backendOverride: deps.getBackendOverride(),
|
||||||
|
getMpvSocketPath: () => deps.getMpvSocketPath(),
|
||||||
|
createWindowTracker: createOverlayWindowTracker,
|
||||||
|
setWindowTracker: (tracker) => {
|
||||||
|
deps.setWindowTracker(tracker);
|
||||||
|
},
|
||||||
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||||
|
deps.updateVisibleOverlayBounds(geometry),
|
||||||
|
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
|
||||||
|
refreshCurrentSubtitle: () => {
|
||||||
|
deps.refreshCurrentSubtitle();
|
||||||
|
},
|
||||||
|
getOverlayWindows: () => deps.getOverlayWindows(),
|
||||||
|
syncOverlayShortcuts: () => deps.syncOverlayShortcuts(),
|
||||||
|
bindOverlayOwner: () => bindVisibleOverlayOwner(),
|
||||||
|
releaseOverlayOwner: () => releaseVisibleOverlayOwner(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function retargetOverlayWindowTrackerForMpvSocket(
|
||||||
|
nextSocketPath: string,
|
||||||
|
previousSocketPath: string,
|
||||||
|
): void {
|
||||||
|
if (nextSocketPath === previousSocketPath || !deps.getOverlayRuntimeInitialized()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousTracker = deps.getWindowTracker();
|
||||||
|
if (previousTracker) {
|
||||||
|
try {
|
||||||
|
previousTracker.stop();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to stop previous overlay window tracker before retargeting', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseVisibleOverlayOwner();
|
||||||
|
deps.setWindowTracker(null);
|
||||||
|
deps.setTrackerNotReadyWarningShown(false);
|
||||||
|
deps.resetLastOverlayWindowGeometry();
|
||||||
|
startOverlayWindowTrackerForCurrentSocket();
|
||||||
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
deps.syncOverlayShortcuts();
|
||||||
|
logger.info(
|
||||||
|
`Retargeted overlay window tracker for MPV socket: ${previousSocketPath} -> ${nextSocketPath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncWindowsVisibleOverlayToMpvZOrder(): Promise<boolean> {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (
|
||||||
|
!mainWindow ||
|
||||||
|
mainWindow.isDestroyed() ||
|
||||||
|
!mainWindow.isVisible() ||
|
||||||
|
!overlayManager.getVisibleOverlayVisible()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowTracker = deps.getWindowTracker();
|
||||||
|
if (!windowTracker) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof windowTracker.isTargetWindowMinimized === 'function' &&
|
||||||
|
windowTracker.isTargetWindowMinimized()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!windowTracker.isTracking() && windowTracker.getGeometry() === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayHwnd = getWindowsNativeWindowHandleNumber(mainWindow);
|
||||||
|
const targetWindowHwnd = resolveWindowsOverlayBindTargetHandle(deps.getMpvSocketPath());
|
||||||
|
if (targetWindowHwnd !== null && bindWindowsOverlayAboveMpv(overlayHwnd, targetWindowHwnd)) {
|
||||||
|
(mainWindow as BrowserWindow & { setOpacity?: (opacity: number) => void }).setOpacity?.(1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWindowsVisibleOverlayZOrderSync(): void {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windowsVisibleOverlayZOrderSyncInFlight) {
|
||||||
|
windowsVisibleOverlayZOrderSyncQueued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowsVisibleOverlayZOrderSyncInFlight = true;
|
||||||
|
void syncWindowsVisibleOverlayToMpvZOrder()
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn('Failed to bind Windows overlay z-order to mpv', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||||
|
if (!windowsVisibleOverlayZOrderSyncQueued) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowsVisibleOverlayZOrderSyncQueued = false;
|
||||||
|
requestWindowsVisibleOverlayZOrderSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWindowsVisibleOverlayZOrderSyncBurst(): void {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearWindowsVisibleOverlayZOrderRetryTimeouts();
|
||||||
|
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS) {
|
||||||
|
const retryTimeout = setTimeout(() => {
|
||||||
|
windowsVisibleOverlayZOrderRetryTimeouts = windowsVisibleOverlayZOrderRetryTimeouts.filter(
|
||||||
|
(timeout) => timeout !== retryTimeout,
|
||||||
|
);
|
||||||
|
requestWindowsVisibleOverlayZOrderSync();
|
||||||
|
}, delayMs);
|
||||||
|
windowsVisibleOverlayZOrderRetryTimeouts.push(retryTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasWindowsVisibleOverlayFocusHandoffGrace(): boolean {
|
||||||
|
return (
|
||||||
|
process.platform === 'win32' &&
|
||||||
|
lastWindowsVisibleOverlayBlurredAtMs > 0 &&
|
||||||
|
Date.now() - lastWindowsVisibleOverlayBlurredAtMs <=
|
||||||
|
WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPollWindowsVisibleOverlayForegroundProcess(): boolean {
|
||||||
|
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowTracker = deps.getWindowTracker();
|
||||||
|
if (!windowTracker) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof windowTracker.isTargetWindowMinimized === 'function' &&
|
||||||
|
windowTracker.isTargetWindowMinimized()
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayFocused = mainWindow.isFocused();
|
||||||
|
const trackerFocused = windowTracker.isTargetWindowFocused?.() ?? false;
|
||||||
|
return !overlayFocused && !trackerFocused;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybePollWindowsVisibleOverlayForegroundProcess(): void {
|
||||||
|
if (!shouldPollWindowsVisibleOverlayForegroundProcess()) {
|
||||||
|
lastWindowsVisibleOverlayForegroundProcessName = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processName = getWindowsForegroundProcessName();
|
||||||
|
const normalizedProcessName = processName?.trim().toLowerCase() ?? null;
|
||||||
|
const previousProcessName = lastWindowsVisibleOverlayForegroundProcessName;
|
||||||
|
lastWindowsVisibleOverlayForegroundProcessName = normalizedProcessName;
|
||||||
|
|
||||||
|
if (normalizedProcessName !== previousProcessName) {
|
||||||
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
}
|
||||||
|
if (normalizedProcessName === 'mpv' && previousProcessName !== 'mpv') {
|
||||||
|
requestWindowsVisibleOverlayZOrderSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWindowsVisibleOverlayForegroundPollLoop(): void {
|
||||||
|
if (process.platform !== 'win32' || windowsVisibleOverlayForegroundPollInterval !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowsVisibleOverlayForegroundPollInterval = setInterval(() => {
|
||||||
|
maybePollWindowsVisibleOverlayForegroundProcess();
|
||||||
|
}, WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWindowsVisibleOverlayForegroundPollLoop(): void {
|
||||||
|
if (windowsVisibleOverlayForegroundPollInterval === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(windowsVisibleOverlayForegroundPollInterval);
|
||||||
|
windowsVisibleOverlayForegroundPollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleVisibleOverlayBlurRefresh(): void {
|
||||||
|
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||||
|
}
|
||||||
|
startMacOSVisibleOverlayForegroundProbe();
|
||||||
|
clearVisibleOverlayBlurRefreshTimeouts();
|
||||||
|
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||||
|
const refreshTimeout = setTimeout(() => {
|
||||||
|
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
|
||||||
|
(timeout) => timeout !== refreshTimeout,
|
||||||
|
);
|
||||||
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
}, delayMs);
|
||||||
|
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureWindowsVisibleOverlayForegroundPollLoop();
|
||||||
|
|
||||||
|
const linuxX11CursorPointReader = createLinuxX11CursorPointReader();
|
||||||
|
|
||||||
|
function getLinuxOverlayPointerMeasurement() {
|
||||||
|
const measurement = overlayContentMeasurementStore.getLatestByLayer('visible');
|
||||||
|
return mapOverlayMeasurementForPointerInteraction(measurement);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSuspendLinuxOverlayPointerInteraction(): boolean {
|
||||||
|
return deps.getModalInputExclusive() || deps.getStatsOverlayVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSuppressLinuxOverlayPointerInteraction(): boolean {
|
||||||
|
return resolveForegroundSuppressionWithGrace({
|
||||||
|
hasForegroundSeparateWindow: hasLiveSeparateWindow(
|
||||||
|
deps.getOverlayForegroundSeparateWindows(),
|
||||||
|
),
|
||||||
|
isTrackingMpvWindow: Boolean(deps.getWindowTracker()?.isTracking()),
|
||||||
|
isMpvWindowFocused: deps.getWindowTracker()?.isTargetWindowFocused?.() !== false,
|
||||||
|
isOverlayWindowFocused: overlayManager.getMainWindow()?.isFocused() === true,
|
||||||
|
nowMs: Date.now(),
|
||||||
|
graceMs: LINUX_POINTER_FOREGROUND_SUPPRESS_GRACE_MS,
|
||||||
|
state: linuxPointerForegroundSuppressionGrace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseLinuxOverlayInputShape(): boolean {
|
||||||
|
// Electron's setShape is a *bounding* shape: outside the given rects no pixels are drawn, so
|
||||||
|
// it clips the visible subtitle (and makes a dragged subtitle vanish behind the shaped
|
||||||
|
// region). There is no input-only region API on Linux, so selective hit-testing is handled by
|
||||||
|
// the main-process cursor poll instead. Keep this off to avoid clipping the overlay.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLinuxVisibleOverlayStartupInputGrace(): boolean {
|
||||||
|
return (
|
||||||
|
process.platform === 'linux' &&
|
||||||
|
linuxVisibleOverlayStartupInputGraceUntilMs > 0 &&
|
||||||
|
Date.now() < linuxVisibleOverlayStartupInputGraceUntilMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLinuxVisibleOverlayStartupInputGrace(): void {
|
||||||
|
linuxVisibleOverlayStartupInputGraceUntilMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLinuxVisibleOverlayStartupInputGrace(): void {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
linuxVisibleOverlayStartupInputGraceUntilMs =
|
||||||
|
Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;
|
||||||
|
linuxOverlayPointerInteractionStateApplied = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLinuxVisibleOverlayStartupInputPrimer(): void {
|
||||||
|
linuxVisibleOverlayStartupInputPrimed = false;
|
||||||
|
clearLinuxVisibleOverlayStartupInputGrace();
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
visibleOverlayInteractionActive = false;
|
||||||
|
linuxOverlayInteractiveHint = false;
|
||||||
|
linuxOverlayPointerInteractionStateApplied = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLinuxOverlayInputShapeFromLatestMeasurement(): boolean {
|
||||||
|
if (!shouldUseLinuxOverlayInputShape()) {
|
||||||
|
linuxOverlayInputShapeActive = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = applyLinuxOverlayInputShape({
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||||
|
getRendererInteractiveHint: () => linuxOverlayInteractiveHint,
|
||||||
|
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||||
|
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||||
|
});
|
||||||
|
linuxOverlayInputShapeActive = result.active;
|
||||||
|
linuxOverlayPointerInteractionStateApplied = result.handled;
|
||||||
|
return result.handled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLinuxOverlayPointerInteractionActive(active: boolean): void {
|
||||||
|
visibleOverlayInteractionActive = active;
|
||||||
|
if (
|
||||||
|
process.platform === 'linux' &&
|
||||||
|
applyLinuxOverlayPointerInteractionMousePassthrough({
|
||||||
|
active,
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||||
|
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||||
|
updateVisibleOverlayVisibility: () => deps.updateVisibleOverlayVisibility(),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
linuxOverlayPointerInteractionStateApplied = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
linuxOverlayPointerInteractionStateApplied = true;
|
||||||
|
deps.updateVisibleOverlayVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function primeLinuxOverlayPointerInteractionAfterFirstMeasurement(): void {
|
||||||
|
if (process.platform !== 'linux') return;
|
||||||
|
if (linuxVisibleOverlayStartupInputPrimed) return;
|
||||||
|
if (shouldUseLinuxOverlayInputShape()) return;
|
||||||
|
if (
|
||||||
|
!shouldPrimeLinuxOverlayInteractionFromMeasurement({
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||||
|
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||||
|
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
linuxVisibleOverlayStartupInputPrimed = true;
|
||||||
|
linuxVisibleOverlayStartupInputGraceUntilMs =
|
||||||
|
Date.now() + LINUX_VISIBLE_OVERLAY_STARTUP_INPUT_GRACE_MS;
|
||||||
|
updateLinuxOverlayPointerInteractionActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linuxOverlayZOrderKeepAliveDeps = {
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
isTrackingMpvWindow: () => Boolean(deps.getWindowTracker()?.isTracking()),
|
||||||
|
isMpvWindowFocused: () => deps.getWindowTracker()?.isTargetWindowFocused?.() !== false,
|
||||||
|
isOverlayWindowFocused: () => overlayManager.getMainWindow()?.isFocused() === true,
|
||||||
|
shouldSuppressReassert: () =>
|
||||||
|
deps.getModalInputExclusive() ||
|
||||||
|
deps.getStatsOverlayVisible() ||
|
||||||
|
hasLiveSeparateWindow(deps.getOverlayForegroundSeparateWindows()) ||
|
||||||
|
(visibleOverlayInteractionActive && overlayManager.getMainWindow()?.isFocused() !== true),
|
||||||
|
raiseMpvWindow: () => {
|
||||||
|
if (
|
||||||
|
lastLinuxVisibleOverlayFollowedMpvAtMs > 0 &&
|
||||||
|
Date.now() - lastLinuxVisibleOverlayFollowedMpvAtMs <=
|
||||||
|
LINUX_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS
|
||||||
|
) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
lastLinuxVisibleOverlayFollowedMpvAtMs = Date.now();
|
||||||
|
return deps.getWindowTracker()?.raiseTargetWindow?.() ?? Promise.resolve(false);
|
||||||
|
},
|
||||||
|
releaseOverlayLayerOrder: () => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
mainWindow.setAlwaysOnTop(false);
|
||||||
|
mainWindow.setFullScreen?.(false);
|
||||||
|
mainWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
|
||||||
|
if (
|
||||||
|
deps.getLinuxVisibleOverlayWindowMode() === 'fullscreen-override' &&
|
||||||
|
mainWindow.isVisible()
|
||||||
|
) {
|
||||||
|
mainWindow.hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
deps.enforceOverlayLayerOrder();
|
||||||
|
},
|
||||||
|
focusOverlayWindow: () => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isFocused()) return;
|
||||||
|
mainWindow.focus();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function requestLinuxOverlayZOrderFollow(): void {
|
||||||
|
if (!shouldRunLinuxOverlayZOrderKeepAlive()) return;
|
||||||
|
void tickLinuxOverlayZOrderKeepAlive(linuxOverlayZOrderKeepAliveDeps).catch((error) => {
|
||||||
|
logger.debug(
|
||||||
|
'Failed to follow tracked mpv behind focused overlay:',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureLinuxOverlayZOrderKeepAliveLoop(linuxOverlayZOrderKeepAliveDeps);
|
||||||
|
|
||||||
|
const linuxOverlayPointerInteractionDeps = {
|
||||||
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
|
getCursorScreenPoint: () =>
|
||||||
|
linuxX11CursorPointReader.getCursorScreenPoint(screen.getCursorScreenPoint()),
|
||||||
|
getSubtitleMeasurement: getLinuxOverlayPointerMeasurement,
|
||||||
|
getRendererInteractiveHint: () =>
|
||||||
|
linuxOverlayInteractiveHint || hasLinuxVisibleOverlayStartupInputGrace(),
|
||||||
|
shouldSuspend: shouldSuspendLinuxOverlayPointerInteraction,
|
||||||
|
shouldSuppressInteraction: shouldSuppressLinuxOverlayPointerInteraction,
|
||||||
|
shouldUseInputShape: shouldUseLinuxOverlayInputShape,
|
||||||
|
getInteractionActive: () => visibleOverlayInteractionActive,
|
||||||
|
isInteractionStateApplied: () => linuxOverlayPointerInteractionStateApplied,
|
||||||
|
setInteractionActive: updateLinuxOverlayPointerInteractionActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
function tickLinuxOverlayPointerInteractionNow(): void {
|
||||||
|
if (applyLinuxOverlayInputShapeFromLatestMeasurement()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tickLinuxOverlayPointerInteraction(linuxOverlayPointerInteractionDeps);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureLinuxOverlayPointerInteractionLoop(linuxOverlayPointerInteractionDeps);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleStatsOverlayVisibilityChanged,
|
||||||
|
resetVisibleOverlayInputState,
|
||||||
|
restoreVisibleOverlayWindowShapeForShow,
|
||||||
|
startMacOSVisibleOverlayForegroundProbe,
|
||||||
|
getNativeWindowHandleDecimal,
|
||||||
|
getWindowsNativeWindowHandle,
|
||||||
|
getWindowsNativeWindowHandleNumber,
|
||||||
|
enqueueVisibleOverlayX11OwnerBindingOperation,
|
||||||
|
clearVisibleOverlayX11OwnerBinding,
|
||||||
|
createOverlayWindowTracker,
|
||||||
|
bindVisibleOverlayOwner,
|
||||||
|
releaseVisibleOverlayOwner,
|
||||||
|
startOverlayWindowTrackerForCurrentSocket,
|
||||||
|
retargetOverlayWindowTrackerForMpvSocket,
|
||||||
|
requestWindowsVisibleOverlayZOrderSync,
|
||||||
|
scheduleWindowsVisibleOverlayZOrderSyncBurst,
|
||||||
|
hasWindowsVisibleOverlayFocusHandoffGrace,
|
||||||
|
ensureWindowsVisibleOverlayForegroundPollLoop,
|
||||||
|
clearWindowsVisibleOverlayForegroundPollLoop,
|
||||||
|
scheduleVisibleOverlayBlurRefresh,
|
||||||
|
getLinuxOverlayPointerMeasurement,
|
||||||
|
hasLinuxVisibleOverlayStartupInputGrace,
|
||||||
|
clearLinuxVisibleOverlayStartupInputGrace,
|
||||||
|
startLinuxVisibleOverlayStartupInputGrace,
|
||||||
|
resetLinuxVisibleOverlayStartupInputPrimer,
|
||||||
|
applyLinuxOverlayInputShapeFromLatestMeasurement,
|
||||||
|
updateLinuxOverlayPointerInteractionActive,
|
||||||
|
primeLinuxOverlayPointerInteractionAfterFirstMeasurement,
|
||||||
|
requestLinuxOverlayZOrderFollow,
|
||||||
|
tickLinuxOverlayPointerInteractionNow,
|
||||||
|
getVisibleOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
||||||
|
setVisibleOverlayInteractionActive: (active: boolean) => {
|
||||||
|
visibleOverlayInteractionActive = active;
|
||||||
|
},
|
||||||
|
getLinuxOverlayInputShapeActive: () => linuxOverlayInputShapeActive,
|
||||||
|
getLastWindowsVisibleOverlayForegroundProcessName: () =>
|
||||||
|
lastWindowsVisibleOverlayForegroundProcessName,
|
||||||
|
getMacOSVisibleOverlayForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive,
|
||||||
|
setLinuxOverlayInteractiveHint: (interactive: boolean) => {
|
||||||
|
linuxOverlayInteractiveHint = interactive;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VisibleOverlayInteractionRuntime = ReturnType<
|
||||||
|
typeof createVisibleOverlayInteractionRuntime
|
||||||
|
>;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { detectWindowsMpvPluginRemovalCandidates } from './first-run-setup-plugin';
|
||||||
|
|
||||||
|
test('Windows plugin removal candidates include portable directory installs', () => {
|
||||||
|
const mpvPath = 'C:\\tools\\mpv\\mpv.exe';
|
||||||
|
const portablePluginDir = 'C:\\tools\\mpv\\portable_config\\scripts\\subminer';
|
||||||
|
const existing = new Set([portablePluginDir]);
|
||||||
|
|
||||||
|
const candidates = detectWindowsMpvPluginRemovalCandidates({
|
||||||
|
homeDir: 'C:\\Users\\tester',
|
||||||
|
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||||
|
mpvExecutablePath: mpvPath,
|
||||||
|
existsSync: (candidate) => existing.has(candidate),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(candidates, [{ path: portablePluginDir, kind: 'directory' }]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { app, dialog, shell } from 'electron';
|
||||||
|
import * as os from 'os';
|
||||||
|
import {
|
||||||
|
detectInstalledMpvPlugin,
|
||||||
|
detectWindowsMpvPluginRemovalCandidates,
|
||||||
|
removeLegacyMpvPluginCandidates,
|
||||||
|
resolvePackagedRuntimePluginPath,
|
||||||
|
} from './first-run-setup-plugin';
|
||||||
|
|
||||||
|
export interface WindowsMpvPluginDetectionRuntimeDeps {
|
||||||
|
mainDirname: string;
|
||||||
|
logWarn: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWindowsMpvPluginDetectionRuntime(
|
||||||
|
deps: WindowsMpvPluginDetectionRuntimeDeps,
|
||||||
|
): {
|
||||||
|
resolveBundledMpvRuntimePluginEntrypoint: () => string | undefined;
|
||||||
|
detectWindowsInstalledMpvPlugin: (
|
||||||
|
mpvExecutablePath: string,
|
||||||
|
) => ReturnType<typeof detectInstalledMpvPlugin>;
|
||||||
|
logInstalledMpvPluginDetected: (detection: {
|
||||||
|
path: string | null;
|
||||||
|
version: string | null;
|
||||||
|
}) => void;
|
||||||
|
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch: (
|
||||||
|
mpvPath: string,
|
||||||
|
detection: { path: string | null; version: string | null },
|
||||||
|
) => Promise<'removed' | 'continue' | 'cancel'>;
|
||||||
|
} {
|
||||||
|
function resolveBundledMpvRuntimePluginEntrypoint(): string | undefined {
|
||||||
|
return (
|
||||||
|
resolvePackagedRuntimePluginPath({
|
||||||
|
dirname: deps.mainDirname,
|
||||||
|
appPath: app.getAppPath(),
|
||||||
|
resourcesPath: process.resourcesPath,
|
||||||
|
}) ?? undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectWindowsInstalledMpvPlugin(mpvExecutablePath: string) {
|
||||||
|
return detectInstalledMpvPlugin({
|
||||||
|
platform: 'win32',
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
appDataDir: app.getPath('appData'),
|
||||||
|
mpvExecutablePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logInstalledMpvPluginDetected(detection: {
|
||||||
|
path: string | null;
|
||||||
|
version: string | null;
|
||||||
|
}) {
|
||||||
|
if (!detection.path) return;
|
||||||
|
deps.logWarn(
|
||||||
|
`SubMiner detected an installed mpv plugin at ${detection.path}. This mpv session will use the installed plugin. Remove it to use the bundled runtime plugin automatically. Detected plugin version: ${detection.version ?? 'unknown or legacy'}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(
|
||||||
|
mpvPath: string,
|
||||||
|
detection: { path: string | null; version: string | null },
|
||||||
|
): Promise<'removed' | 'continue' | 'cancel'> {
|
||||||
|
const response = await dialog.showMessageBox({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'SubMiner mpv plugin detected',
|
||||||
|
message: [
|
||||||
|
'SubMiner detected an installed mpv plugin at:',
|
||||||
|
detection.path ?? 'unknown path',
|
||||||
|
'',
|
||||||
|
"This mpv session will use the installed plugin unless it is removed. Remove it now to use SubMiner's bundled runtime plugin automatically.",
|
||||||
|
`Detected plugin version: ${detection.version ?? 'unknown or legacy'}`,
|
||||||
|
].join('\n'),
|
||||||
|
detail:
|
||||||
|
'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash.',
|
||||||
|
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.response === 2) {
|
||||||
|
return 'cancel';
|
||||||
|
}
|
||||||
|
if (response.response === 1) {
|
||||||
|
return 'continue';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await removeLegacyMpvPluginCandidates({
|
||||||
|
candidates: detectWindowsMpvPluginRemovalCandidates({
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
appDataDir: app.getPath('appData'),
|
||||||
|
mpvExecutablePath: mpvPath,
|
||||||
|
}),
|
||||||
|
trashItem: (candidatePath) => shell.trashItem(candidatePath),
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
await dialog.showMessageBox({
|
||||||
|
type: 'info',
|
||||||
|
title: 'Legacy mpv plugin removed',
|
||||||
|
message:
|
||||||
|
'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
|
||||||
|
});
|
||||||
|
return 'removed';
|
||||||
|
}
|
||||||
|
|
||||||
|
await dialog.showMessageBox({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Could not remove legacy mpv plugin',
|
||||||
|
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
|
||||||
|
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
|
||||||
|
});
|
||||||
|
return 'cancel';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolveBundledMpvRuntimePluginEntrypoint,
|
||||||
|
detectWindowsInstalledMpvPlugin,
|
||||||
|
logInstalledMpvPluginDetected,
|
||||||
|
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildYomitanAnkiSettingsKey } from './yomitan-anki-server-sync';
|
||||||
|
|
||||||
|
test('buildYomitanAnkiSettingsKey includes force override policy', () => {
|
||||||
|
assert.notEqual(
|
||||||
|
buildYomitanAnkiSettingsKey({
|
||||||
|
targetUrl: 'http://127.0.0.1:8766',
|
||||||
|
targetDeck: 'Mining',
|
||||||
|
forceOverride: false,
|
||||||
|
}),
|
||||||
|
buildYomitanAnkiSettingsKey({
|
||||||
|
targetUrl: 'http://127.0.0.1:8766',
|
||||||
|
targetDeck: 'Mining',
|
||||||
|
forceOverride: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore } from '../../core/services';
|
||||||
|
import type { ResolvedConfig } from '../../types';
|
||||||
|
import {
|
||||||
|
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||||
|
shouldForceOverrideYomitanAnkiServer,
|
||||||
|
} from './yomitan-anki-server';
|
||||||
|
|
||||||
|
export interface YomitanAnkiServerSyncRuntimeDeps {
|
||||||
|
isExternalReadOnlyMode: () => boolean;
|
||||||
|
getResolvedConfig: () => ResolvedConfig;
|
||||||
|
getYomitanParserRuntimeDeps: () => Parameters<typeof syncYomitanDefaultAnkiServerCore>[1];
|
||||||
|
logError: (message: string, ...args: unknown[]) => void;
|
||||||
|
logInfo: (message: string, ...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildYomitanAnkiSettingsKey(options: {
|
||||||
|
targetUrl: string;
|
||||||
|
targetDeck: string;
|
||||||
|
forceOverride: boolean;
|
||||||
|
}): string {
|
||||||
|
return `${options.targetUrl}\n${options.targetDeck}\nforceOverride:${options.forceOverride}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createYomitanAnkiServerSyncRuntime(deps: YomitanAnkiServerSyncRuntimeDeps): {
|
||||||
|
syncYomitanDefaultProfileAnkiServer: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
let lastSyncedYomitanAnkiSettingsKey: string | null = null;
|
||||||
|
|
||||||
|
function getPreferredYomitanAnkiServerUrl(): string {
|
||||||
|
return getPreferredYomitanAnkiServerUrlRuntime(deps.getResolvedConfig().ankiConnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
||||||
|
if (deps.isExternalReadOnlyMode()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
|
||||||
|
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
||||||
|
const targetDeck = ankiConnectConfig?.deck?.trim() ?? '';
|
||||||
|
const forceOverride = ankiConnectConfig
|
||||||
|
? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig)
|
||||||
|
: false;
|
||||||
|
const targetSettingsKey = buildYomitanAnkiSettingsKey({
|
||||||
|
targetUrl,
|
||||||
|
targetDeck,
|
||||||
|
forceOverride,
|
||||||
|
});
|
||||||
|
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const synced = await syncYomitanDefaultAnkiServerCore(
|
||||||
|
targetUrl,
|
||||||
|
deps.getYomitanParserRuntimeDeps(),
|
||||||
|
{
|
||||||
|
error: (message, ...args) => {
|
||||||
|
deps.logError(message, ...args);
|
||||||
|
},
|
||||||
|
info: (message, ...args) => {
|
||||||
|
deps.logInfo(message, ...args);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
forceOverride,
|
||||||
|
deck: targetDeck,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (synced) {
|
||||||
|
lastSyncedYomitanAnkiSettingsKey = targetSettingsKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { syncYomitanDefaultProfileAnkiServer };
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { createMouseHandlers } from './mouse.js';
|
|||||||
import {
|
import {
|
||||||
YOMITAN_POPUP_HIDDEN_EVENT,
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
YOMITAN_POPUP_HOST_SELECTOR,
|
YOMITAN_POPUP_HOST_SELECTOR,
|
||||||
|
YOMITAN_POPUP_MOUSE_ENTER_EVENT,
|
||||||
|
YOMITAN_POPUP_MOUSE_LEAVE_EVENT,
|
||||||
YOMITAN_POPUP_SHOWN_EVENT,
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
YOMITAN_POPUP_VISIBLE_HOST_SELECTOR,
|
||||||
} from '../yomitan-popup.js';
|
} from '../yomitan-popup.js';
|
||||||
@@ -89,6 +91,7 @@ function createMouseTestContext() {
|
|||||||
state: {
|
state: {
|
||||||
isOverSubtitle: false,
|
isOverSubtitle: false,
|
||||||
isOverSubtitleSidebar: false,
|
isOverSubtitleSidebar: false,
|
||||||
|
isOverYomitanPopup: false,
|
||||||
yomitanPopupVisible: false,
|
yomitanPopupVisible: false,
|
||||||
subtitleSidebarModalOpen: false,
|
subtitleSidebarModalOpen: false,
|
||||||
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
subtitleSidebarConfig: null as SubtitleSidebarConfig | null,
|
||||||
@@ -842,7 +845,12 @@ test('nested popup close reasserts interactive state and focus when another popu
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => {
|
function setupYomitanPopupFocusHarness(
|
||||||
|
options: {
|
||||||
|
isMacOSPlatform?: boolean;
|
||||||
|
visiblePopupHost?: boolean;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
const ctx = createMouseTestContext();
|
const ctx = createMouseTestContext();
|
||||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||||
const previousDocument = (globalThis as { document?: unknown }).document;
|
const previousDocument = (globalThis as { document?: unknown }).document;
|
||||||
@@ -853,8 +861,10 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
|
|||||||
let focusMainWindowCalls = 0;
|
let focusMainWindowCalls = 0;
|
||||||
let windowFocusCalls = 0;
|
let windowFocusCalls = 0;
|
||||||
let overlayFocusCalls = 0;
|
let overlayFocusCalls = 0;
|
||||||
|
let visiblePopupHostPresent = options.visiblePopupHost === true;
|
||||||
|
|
||||||
ctx.platform.shouldToggleMouseIgnore = true;
|
ctx.platform.shouldToggleMouseIgnore = true;
|
||||||
|
ctx.platform.isMacOSPlatform = options.isMacOSPlatform === true;
|
||||||
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
|
(ctx.dom.overlay as { focus?: (options?: { preventScroll?: boolean }) => void }).focus = () => {
|
||||||
overlayFocusCalls += 1;
|
overlayFocusCalls += 1;
|
||||||
};
|
};
|
||||||
@@ -902,8 +912,8 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
|
|||||||
querySelector: () => null,
|
querySelector: () => null,
|
||||||
querySelectorAll: (selector: string) => {
|
querySelectorAll: (selector: string) => {
|
||||||
if (
|
if (
|
||||||
selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR ||
|
(visiblePopupHostPresent && selector === YOMITAN_POPUP_VISIBLE_HOST_SELECTOR) ||
|
||||||
selector === YOMITAN_POPUP_HOST_SELECTOR
|
(visiblePopupHostPresent && selector === YOMITAN_POPUP_HOST_SELECTOR)
|
||||||
) {
|
) {
|
||||||
return [visiblePopupHost];
|
return [visiblePopupHost];
|
||||||
}
|
}
|
||||||
@@ -927,46 +937,181 @@ test('window blur reclaims overlay focus while a yomitan popup remains visible o
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handlers = createMouseHandlers(ctx as never, {
|
||||||
|
modalStateReader: {
|
||||||
|
isAnySettingsModalOpen: () => false,
|
||||||
|
isAnyModalOpen: () => false,
|
||||||
|
},
|
||||||
|
applyYPercent: () => {},
|
||||||
|
getCurrentYPercent: () => 10,
|
||||||
|
persistSubtitlePositionPatch: () => {},
|
||||||
|
getSubtitleHoverAutoPauseEnabled: () => false,
|
||||||
|
getYomitanPopupAutoPauseEnabled: () => false,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: () => {},
|
||||||
|
});
|
||||||
|
handlers.setupYomitanObserver();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
windowListeners,
|
||||||
|
ignoreCalls,
|
||||||
|
focusMainWindowCalls: () => focusMainWindowCalls,
|
||||||
|
windowFocusCalls: () => windowFocusCalls,
|
||||||
|
overlayFocusCalls: () => overlayFocusCalls,
|
||||||
|
setVisiblePopupHost: (visible: boolean) => {
|
||||||
|
visiblePopupHostPresent = visible;
|
||||||
|
},
|
||||||
|
restore: () => {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousDocument,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'MutationObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: previousMutationObserver,
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('window blur reclaims overlay focus while a yomitan popup remains visible on Windows', async () => {
|
||||||
|
const harness = setupYomitanPopupFocusHarness({ visiblePopupHost: true });
|
||||||
try {
|
try {
|
||||||
const handlers = createMouseHandlers(ctx as never, {
|
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
|
||||||
modalStateReader: {
|
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
isAnySettingsModalOpen: () => false,
|
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
isAnyModalOpen: () => false,
|
harness.ignoreCalls.length = 0;
|
||||||
},
|
|
||||||
applyYPercent: () => {},
|
|
||||||
getCurrentYPercent: () => 10,
|
|
||||||
persistSubtitlePositionPatch: () => {},
|
|
||||||
getSubtitleHoverAutoPauseEnabled: () => false,
|
|
||||||
getYomitanPopupAutoPauseEnabled: () => false,
|
|
||||||
getPlaybackPaused: async () => false,
|
|
||||||
sendMpvCommand: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
handlers.setupYomitanObserver();
|
for (const listener of harness.windowListeners.get('blur') ?? []) {
|
||||||
assert.equal(ctx.state.yomitanPopupVisible, true);
|
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
|
||||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
|
||||||
ignoreCalls.length = 0;
|
|
||||||
|
|
||||||
for (const listener of windowListeners.get('blur') ?? []) {
|
|
||||||
listener();
|
listener();
|
||||||
}
|
}
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
assert.equal(ctx.state.yomitanPopupVisible, true);
|
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
|
||||||
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
|
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
assert.equal(focusMainWindowCalls, 1);
|
assert.equal(harness.focusMainWindowCalls(), 1);
|
||||||
assert.equal(windowFocusCalls, 1);
|
assert.equal(harness.windowFocusCalls(), 1);
|
||||||
assert.equal(overlayFocusCalls, 1);
|
assert.equal(harness.overlayFocusCalls(), 1);
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
harness.restore();
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
}
|
||||||
Object.defineProperty(globalThis, 'MutationObserver', {
|
});
|
||||||
configurable: true,
|
|
||||||
value: previousMutationObserver,
|
test('window blur on macOS keeps yomitan popup interactive without stealing click-away focus', async () => {
|
||||||
});
|
const harness = setupYomitanPopupFocusHarness({
|
||||||
Object.defineProperty(globalThis, 'Node', { configurable: true, value: previousNode });
|
isMacOSPlatform: true,
|
||||||
|
visiblePopupHost: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
|
||||||
|
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
harness.ignoreCalls.length = 0;
|
||||||
|
|
||||||
|
for (const listener of harness.windowListeners.get('blur') ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
|
||||||
|
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
assert.equal(harness.focusMainWindowCalls(), 0);
|
||||||
|
assert.equal(harness.windowFocusCalls(), 0);
|
||||||
|
assert.equal(harness.overlayFocusCalls(), 0);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('popup shown reclaims overlay focus on macOS and captures click-away', () => {
|
||||||
|
const harness = setupYomitanPopupFocusHarness({ isMacOSPlatform: true });
|
||||||
|
try {
|
||||||
|
harness.ignoreCalls.length = 0;
|
||||||
|
|
||||||
|
for (const listener of harness.windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
|
||||||
|
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
assert.equal(harness.focusMainWindowCalls(), 1);
|
||||||
|
assert.equal(harness.windowFocusCalls(), 1);
|
||||||
|
assert.equal(harness.overlayFocusCalls(), 1);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('popup mouse enter marks macOS Yomitan popup hover interactive', () => {
|
||||||
|
const harness = setupYomitanPopupFocusHarness({
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
visiblePopupHost: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
harness.ignoreCalls.length = 0;
|
||||||
|
|
||||||
|
for (const listener of harness.windowListeners.get(YOMITAN_POPUP_MOUSE_ENTER_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
|
||||||
|
assert.equal(harness.ctx.state.isOverYomitanPopup, true);
|
||||||
|
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('popup mouse leave on macOS keeps click-away captured while popup remains visible', () => {
|
||||||
|
const harness = setupYomitanPopupFocusHarness({
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
visiblePopupHost: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (const listener of harness.windowListeners.get(YOMITAN_POPUP_MOUSE_ENTER_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
harness.ignoreCalls.length = 0;
|
||||||
|
|
||||||
|
for (const listener of harness.windowListeners.get(YOMITAN_POPUP_MOUSE_LEAVE_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(harness.ctx.state.yomitanPopupVisible, true);
|
||||||
|
assert.equal(harness.ctx.state.isOverYomitanPopup, false);
|
||||||
|
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(harness.ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('popup hidden on macOS releases click-away capture back to mpv', () => {
|
||||||
|
const harness = setupYomitanPopupFocusHarness({
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
visiblePopupHost: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), true);
|
||||||
|
harness.ignoreCalls.length = 0;
|
||||||
|
harness.setVisiblePopupHost(false);
|
||||||
|
|
||||||
|
for (const listener of harness.windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(harness.ctx.state.yomitanPopupVisible, false);
|
||||||
|
assert.equal(harness.ctx.dom.overlay.classList.contains('interactive'), false);
|
||||||
|
assert.deepEqual(harness.ignoreCalls.at(-1), { ignore: true, forward: true });
|
||||||
|
} finally {
|
||||||
|
harness.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function createMouseHandlers(
|
|||||||
if (!ctx.platform.shouldToggleMouseIgnore) {
|
if (!ctx.platform.shouldToggleMouseIgnore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ctx.platform.isMacOSPlatform || ctx.platform.isLinuxPlatform) {
|
if (ctx.platform.isLinuxPlatform) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +305,7 @@ export function createMouseHandlers(
|
|||||||
|
|
||||||
yomitanPopupVisible = false;
|
yomitanPopupVisible = false;
|
||||||
ctx.state.yomitanPopupVisible = false;
|
ctx.state.yomitanPopupVisible = false;
|
||||||
|
ctx.state.isOverYomitanPopup = false;
|
||||||
syncPrimaryVisibleOnYomitanPopupClass(false);
|
syncPrimaryVisibleOnYomitanPopupClass(false);
|
||||||
popupPauseRequestId += 1;
|
popupPauseRequestId += 1;
|
||||||
maybeResumeYomitanPopupPause();
|
maybeResumeYomitanPopupPause();
|
||||||
@@ -378,7 +379,10 @@ export function createMouseHandlers(
|
|||||||
}
|
}
|
||||||
hoverPauseRequestId += 1;
|
hoverPauseRequestId += 1;
|
||||||
maybeResumeHoverPause();
|
maybeResumeHoverPause();
|
||||||
if (yomitanPopupVisible) return;
|
if (yomitanPopupVisible) {
|
||||||
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
disablePopupInteractionIfIdle();
|
disablePopupInteractionIfIdle();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +471,11 @@ export function createMouseHandlers(
|
|||||||
reconcilePopupInteraction({ allowPause: true });
|
reconcilePopupInteraction({ allowPause: true });
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, () => {
|
||||||
reconcilePopupInteraction({ assumeVisible: true, allowPause: true });
|
reconcilePopupInteraction({
|
||||||
|
assumeVisible: true,
|
||||||
|
allowPause: true,
|
||||||
|
reclaimFocus: ctx.platform.isMacOSPlatform,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
@@ -475,10 +483,12 @@ export function createMouseHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_MOUSE_ENTER_EVENT, () => {
|
||||||
|
ctx.state.isOverYomitanPopup = true;
|
||||||
reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true });
|
reconcilePopupInteraction({ assumeVisible: true, reclaimFocus: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
|
||||||
|
ctx.state.isOverYomitanPopup = false;
|
||||||
reconcilePopupInteraction({ assumeVisible: true });
|
reconcilePopupInteraction({ assumeVisible: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -491,7 +501,7 @@ export function createMouseHandlers(
|
|||||||
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
|
if (typeof document === 'undefined' || document.visibilityState !== 'visible') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reconcilePopupInteraction({ reclaimFocus: true });
|
reconcilePopupInteraction({ reclaimFocus: !ctx.platform.isMacOSPlatform });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,111 @@ test('visible yomitan popup host keeps overlay interactive even when cached popu
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('visible yomitan popup host on macOS keeps overlay interactive so click-away reaches popup', () => {
|
||||||
|
const classList = createClassList();
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
|
||||||
|
const restoreWindow = replaceGlobalProperty('window', {
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getComputedStyle: () => ({
|
||||||
|
visibility: 'visible',
|
||||||
|
display: 'block',
|
||||||
|
opacity: '1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const restoreDocument = replaceGlobalProperty('document', {
|
||||||
|
querySelectorAll: (selector: string) =>
|
||||||
|
selector ===
|
||||||
|
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
|
||||||
|
? [{ getAttribute: () => 'true' }]
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
syncOverlayMouseIgnoreState({
|
||||||
|
dom: {
|
||||||
|
overlay: { classList },
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
shouldToggleMouseIgnore: true,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
isOverSubtitle: false,
|
||||||
|
isOverSubtitleSidebar: false,
|
||||||
|
isOverYomitanPopup: false,
|
||||||
|
yomitanPopupVisible: false,
|
||||||
|
controllerSelectModalOpen: false,
|
||||||
|
controllerDebugModalOpen: false,
|
||||||
|
jimakuModalOpen: false,
|
||||||
|
youtubePickerModalOpen: false,
|
||||||
|
kikuModalOpen: false,
|
||||||
|
runtimeOptionsModalOpen: false,
|
||||||
|
subsyncModalOpen: false,
|
||||||
|
sessionHelpModalOpen: false,
|
||||||
|
subtitleSidebarModalOpen: false,
|
||||||
|
subtitleSidebarConfig: null,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
} finally {
|
||||||
|
restoreDocument();
|
||||||
|
restoreWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS pointer over a visible yomitan popup keeps the overlay interactive', () => {
|
||||||
|
const classList = createClassList();
|
||||||
|
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
|
||||||
|
|
||||||
|
const restoreWindow = replaceGlobalProperty('window', {
|
||||||
|
electronAPI: {
|
||||||
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
|
ignoreCalls.push({ ignore, forward: options?.forward });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
syncOverlayMouseIgnoreState({
|
||||||
|
dom: {
|
||||||
|
overlay: { classList },
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
isMacOSPlatform: true,
|
||||||
|
shouldToggleMouseIgnore: true,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
isOverSubtitle: false,
|
||||||
|
isOverSubtitleSidebar: false,
|
||||||
|
isOverYomitanPopup: true,
|
||||||
|
yomitanPopupVisible: true,
|
||||||
|
controllerSelectModalOpen: false,
|
||||||
|
controllerDebugModalOpen: false,
|
||||||
|
jimakuModalOpen: false,
|
||||||
|
youtubePickerModalOpen: false,
|
||||||
|
kikuModalOpen: false,
|
||||||
|
runtimeOptionsModalOpen: false,
|
||||||
|
subsyncModalOpen: false,
|
||||||
|
sessionHelpModalOpen: false,
|
||||||
|
subtitleSidebarModalOpen: false,
|
||||||
|
subtitleSidebarConfig: null,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(classList.contains('interactive'), true);
|
||||||
|
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
|
||||||
|
} finally {
|
||||||
|
restoreWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('Linux subtitle hover keeps root passive and does not report whole-window interactive hint', () => {
|
test('Linux subtitle hover keeps root passive and does not report whole-window interactive hint', () => {
|
||||||
const classList = createClassList();
|
const classList = createClassList();
|
||||||
const interactiveHints: boolean[] = [];
|
const interactiveHints: boolean[] = [];
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
|
|||||||
const shouldStayInteractive =
|
const shouldStayInteractive =
|
||||||
ctx.state.isOverSubtitle ||
|
ctx.state.isOverSubtitle ||
|
||||||
ctx.state.isOverSubtitleSidebar ||
|
ctx.state.isOverSubtitleSidebar ||
|
||||||
|
ctx.state.isOverYomitanPopup ||
|
||||||
ctx.state.isOverOverlayNotification ||
|
ctx.state.isOverOverlayNotification ||
|
||||||
ctx.state.isOverNotificationHistory ||
|
ctx.state.isOverNotificationHistory ||
|
||||||
shouldKeepWindowInteractive;
|
shouldKeepWindowInteractive;
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export type RendererState = {
|
|||||||
keyboardSelectionVisible: boolean;
|
keyboardSelectionVisible: boolean;
|
||||||
keyboardSelectedWordIndex: number | null;
|
keyboardSelectedWordIndex: number | null;
|
||||||
yomitanPopupVisible: boolean;
|
yomitanPopupVisible: boolean;
|
||||||
|
isOverYomitanPopup: boolean;
|
||||||
primarySubtitleMode: PrimarySubMode;
|
primarySubtitleMode: PrimarySubMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -254,6 +255,7 @@ export function createRendererState(): RendererState {
|
|||||||
keyboardSelectionVisible: false,
|
keyboardSelectionVisible: false,
|
||||||
keyboardSelectedWordIndex: null,
|
keyboardSelectedWordIndex: null,
|
||||||
yomitanPopupVisible: false,
|
yomitanPopupVisible: false,
|
||||||
|
isOverYomitanPopup: false,
|
||||||
primarySubtitleMode: 'visible',
|
primarySubtitleMode: 'visible',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ test('stats daemon control stops live daemon and treats stale state as success',
|
|||||||
calls.push(`isProcessAlive:${pid}:${aliveChecks}`);
|
calls.push(`isProcessAlive:${pid}:${aliveChecks}`);
|
||||||
return aliveChecks === 1;
|
return aliveChecks === 1;
|
||||||
},
|
},
|
||||||
|
verifyProcessIdentity: (pid, startedAtMs) => {
|
||||||
|
calls.push(`verifyProcessIdentity:${pid}:${startedAtMs}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
resolveUrl: (state) => `http://127.0.0.1:${state.port}`,
|
resolveUrl: (state) => `http://127.0.0.1:${state.port}`,
|
||||||
spawnDaemon: async () => 1,
|
spawnDaemon: async () => 1,
|
||||||
waitForDaemonResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
waitForDaemonResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
||||||
@@ -147,6 +151,7 @@ test('stats daemon control stops live daemon and treats stale state as success',
|
|||||||
assert.equal(exitCode, 0);
|
assert.equal(exitCode, 0);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'isProcessAlive:4242:1',
|
'isProcessAlive:4242:1',
|
||||||
|
'verifyProcessIdentity:4242:1',
|
||||||
'killProcess:4242:SIGTERM',
|
'killProcess:4242:SIGTERM',
|
||||||
'isProcessAlive:4242:2',
|
'isProcessAlive:4242:2',
|
||||||
'removeState',
|
'removeState',
|
||||||
@@ -158,3 +163,47 @@ test('stats daemon control stops live daemon and treats stale state as success',
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stats daemon control clears stale state when daemon identity mismatches', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const responses: Array<{ path: string; payload: { ok: boolean; url?: string; error?: string } }> =
|
||||||
|
[];
|
||||||
|
const handler = createRunStatsDaemonControlHandler({
|
||||||
|
statePath: '/tmp/stats-daemon.json',
|
||||||
|
readState: () => ({ pid: 4242, port: 5175, startedAtMs: 1 }),
|
||||||
|
removeState: () => {
|
||||||
|
calls.push('removeState');
|
||||||
|
},
|
||||||
|
isProcessAlive: (pid) => {
|
||||||
|
calls.push(`isProcessAlive:${pid}`);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
verifyProcessIdentity: (pid, startedAtMs) => {
|
||||||
|
calls.push(`verifyProcessIdentity:${pid}:${startedAtMs}`);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
resolveUrl: (state) => `http://127.0.0.1:${state.port}`,
|
||||||
|
spawnDaemon: async () => 1,
|
||||||
|
waitForDaemonResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
||||||
|
openExternal: async () => {},
|
||||||
|
writeResponse: (responsePath, payload) => {
|
||||||
|
responses.push({ path: responsePath, payload });
|
||||||
|
},
|
||||||
|
killProcess: () => {
|
||||||
|
calls.push('killProcess');
|
||||||
|
},
|
||||||
|
sleep: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitCode = await handler({
|
||||||
|
action: 'stop',
|
||||||
|
responsePath: '/tmp/response.json',
|
||||||
|
openBrowser: false,
|
||||||
|
daemonScriptPath: '/tmp/stats-daemon-runner.js',
|
||||||
|
userDataPath: '/tmp/SubMiner',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(exitCode, 0);
|
||||||
|
assert.deepEqual(calls, ['isProcessAlive:4242', 'verifyProcessIdentity:4242:1', 'removeState']);
|
||||||
|
assert.deepEqual(responses, [{ path: '/tmp/response.json', payload: { ok: true } }]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BackgroundStatsServerState } from './main/runtime/stats-daemon';
|
import type { BackgroundStatsServerState } from './main/runtime/stats-daemon';
|
||||||
|
import { verifyBackgroundStatsServerIdentity } from './main/runtime/stats-daemon';
|
||||||
import type { StatsCliCommandResponse } from './main/runtime/stats-cli-command';
|
import type { StatsCliCommandResponse } from './main/runtime/stats-cli-command';
|
||||||
|
|
||||||
export type StatsDaemonControlAction = 'start' | 'stop';
|
export type StatsDaemonControlAction = 'start' | 'stop';
|
||||||
@@ -22,6 +23,7 @@ export function createRunStatsDaemonControlHandler(deps: {
|
|||||||
readState: () => BackgroundStatsServerState | null;
|
readState: () => BackgroundStatsServerState | null;
|
||||||
removeState: () => void;
|
removeState: () => void;
|
||||||
isProcessAlive: (pid: number) => boolean;
|
isProcessAlive: (pid: number) => boolean;
|
||||||
|
verifyProcessIdentity?: (pid: number, startedAtMs: number) => boolean;
|
||||||
resolveUrl: (state: Pick<BackgroundStatsServerState, 'port'>) => string;
|
resolveUrl: (state: Pick<BackgroundStatsServerState, 'port'>) => string;
|
||||||
spawnDaemon: (options: SpawnStatsDaemonOptions) => Promise<number> | number;
|
spawnDaemon: (options: SpawnStatsDaemonOptions) => Promise<number> | number;
|
||||||
waitForDaemonResponse: (responsePath: string) => Promise<StatsCliCommandResponse>;
|
waitForDaemonResponse: (responsePath: string) => Promise<StatsCliCommandResponse>;
|
||||||
@@ -81,6 +83,12 @@ export function createRunStatsDaemonControlHandler(deps: {
|
|||||||
writeResponseSafe(args.responsePath, { ok: true });
|
writeResponseSafe(args.responsePath, { ok: true });
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
const verifyProcessIdentity = deps.verifyProcessIdentity ?? verifyBackgroundStatsServerIdentity;
|
||||||
|
if (!verifyProcessIdentity(state.pid, state.startedAtMs)) {
|
||||||
|
deps.removeState();
|
||||||
|
writeResponseSafe(args.responsePath, { ok: true });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
deps.killProcess(state.pid, 'SIGTERM');
|
deps.killProcess(state.pid, 'SIGTERM');
|
||||||
const deadline = Date.now() + 2_000;
|
const deadline = Date.now() + 2_000;
|
||||||
|
|||||||
Reference in New Issue
Block a user